feat(FN-3159): move roadmap ownership from core to fusion-plugin-roadmap pl
Moves roadmap ownership from `@fusion/core` to `fusion-plugin-roadmap`, deleting ~1,800 lines of roadmap store, types, ordering, and handoff logic from core and replacing it with ~760 lines of new route handlers in the plugin. The dashboard's roadmap routes and suggestions modules are substantially Fusion-Task-Id: FN-3159
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) ───────────────────────────
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* Pure mapping helpers for converting roadmap hierarchy data into mission/task planning handoffs.
|
||||
*
|
||||
* These helpers are read-only transformations that preserve source lineage and deterministic
|
||||
* ordering without coupling to MissionStore or task persistence.
|
||||
*
|
||||
* @module roadmap-handoff
|
||||
*/
|
||||
|
||||
import type {
|
||||
Roadmap,
|
||||
RoadmapMilestone,
|
||||
RoadmapFeature,
|
||||
RoadmapWithHierarchy,
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
RoadmapFeatureSourceRef,
|
||||
RoadmapMissionPlanningMilestoneHandoff,
|
||||
} from "./roadmap-types.js";
|
||||
|
||||
import {
|
||||
normalizeRoadmapMilestoneOrder,
|
||||
normalizeRoadmapFeatureOrder,
|
||||
} from "./roadmap-ordering.js";
|
||||
|
||||
/**
|
||||
* Build a source reference for a roadmap feature.
|
||||
*
|
||||
* Includes roadmap and milestone context for downstream planning prompts.
|
||||
*/
|
||||
function buildFeatureSourceRef(
|
||||
roadmap: Roadmap,
|
||||
milestone: RoadmapMilestone,
|
||||
feature: RoadmapFeature,
|
||||
): RoadmapFeatureSourceRef {
|
||||
return {
|
||||
roadmapId: roadmap.id,
|
||||
milestoneId: milestone.id,
|
||||
featureId: feature.id,
|
||||
roadmapTitle: roadmap.title,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneOrderIndex: milestone.orderIndex,
|
||||
featureOrderIndex: feature.orderIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single roadmap feature into a task planning handoff payload.
|
||||
*
|
||||
* The handoff preserves source lineage for traceability and deterministic ordering
|
||||
* for consistent downstream processing.
|
||||
*/
|
||||
export function mapFeatureToTaskHandoff(
|
||||
roadmap: Roadmap,
|
||||
milestone: RoadmapMilestone,
|
||||
feature: RoadmapFeature,
|
||||
): RoadmapFeatureTaskPlanningHandoff {
|
||||
return {
|
||||
source: buildFeatureSourceRef(roadmap, milestone, feature),
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a full roadmap hierarchy into a mission planning handoff payload.
|
||||
*
|
||||
* The handoff preserves deterministic ordering by normalizing milestone and feature
|
||||
* order indices before building the payload.
|
||||
*
|
||||
* @param roadmap - The roadmap to convert
|
||||
* @param milestones - Ordered milestones (will be re-normalized for deterministic output)
|
||||
* @param featuresByMilestoneId - Features grouped by milestone ID
|
||||
* @returns Mission planning handoff payload
|
||||
*/
|
||||
export function mapRoadmapToMissionHandoff(
|
||||
roadmap: Roadmap,
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
featuresByMilestoneId: ReadonlyMap<string, readonly RoadmapFeature[]>,
|
||||
): RoadmapMissionPlanningHandoff {
|
||||
// Normalize milestone ordering deterministically
|
||||
const normalizedMilestones = normalizeRoadmapMilestoneOrder(milestones);
|
||||
|
||||
const milestoneHandoffs: RoadmapMissionPlanningMilestoneHandoff[] = normalizedMilestones.map((milestone) => {
|
||||
// Get features for this milestone and normalize their order
|
||||
const rawFeatures = featuresByMilestoneId.get(milestone.id) ?? [];
|
||||
const normalizedFeatures = normalizeRoadmapFeatureOrder(rawFeatures);
|
||||
|
||||
return {
|
||||
sourceMilestoneId: milestone.id,
|
||||
title: milestone.title,
|
||||
description: milestone.description,
|
||||
orderIndex: milestone.orderIndex,
|
||||
features: normalizedFeatures.map((feature) => ({
|
||||
sourceFeatureId: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
orderIndex: feature.orderIndex,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
sourceRoadmapId: roadmap.id,
|
||||
title: roadmap.title,
|
||||
description: roadmap.description,
|
||||
milestones: milestoneHandoffs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a roadmap with full hierarchy into a mission planning handoff payload.
|
||||
*
|
||||
* Convenience overload that accepts the composite RoadmapWithHierarchy type.
|
||||
*/
|
||||
export function mapRoadmapWithHierarchyToMissionHandoff(
|
||||
roadmapWithHierarchy: RoadmapWithHierarchy,
|
||||
): RoadmapMissionPlanningHandoff {
|
||||
// Build features by milestone ID map
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
for (const milestone of roadmapWithHierarchy.milestones) {
|
||||
featuresByMilestoneId.set(milestone.id, milestone.features);
|
||||
}
|
||||
|
||||
return mapRoadmapToMissionHandoff(
|
||||
roadmapWithHierarchy,
|
||||
roadmapWithHierarchy.milestones,
|
||||
featuresByMilestoneId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all features from a roadmap into task planning handoff payloads.
|
||||
*
|
||||
* Flattens the roadmap hierarchy into individual feature handoffs, each preserving
|
||||
* source lineage and deterministic ordering.
|
||||
*
|
||||
* @param roadmap - The parent roadmap
|
||||
* @param milestones - Ordered milestones (will be re-normalized for deterministic output)
|
||||
* @param featuresByMilestoneId - Features grouped by milestone ID
|
||||
* @returns Array of feature task planning handoffs, ordered by milestone order then feature order
|
||||
*/
|
||||
export function mapAllFeaturesToTaskHandoffs(
|
||||
roadmap: Roadmap,
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
featuresByMilestoneId: ReadonlyMap<string, readonly RoadmapFeature[]>,
|
||||
): RoadmapFeatureTaskPlanningHandoff[] {
|
||||
// Normalize milestone ordering deterministically
|
||||
const normalizedMilestones = normalizeRoadmapMilestoneOrder(milestones);
|
||||
|
||||
const handoffs: RoadmapFeatureTaskPlanningHandoff[] = [];
|
||||
|
||||
for (const milestone of normalizedMilestones) {
|
||||
const rawFeatures = featuresByMilestoneId.get(milestone.id) ?? [];
|
||||
const normalizedFeatures = normalizeRoadmapFeatureOrder(rawFeatures);
|
||||
|
||||
for (const feature of normalizedFeatures) {
|
||||
handoffs.push(mapFeatureToTaskHandoff(roadmap, milestone, feature));
|
||||
}
|
||||
}
|
||||
|
||||
return handoffs;
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
import type {
|
||||
RoadmapFeature,
|
||||
RoadmapFeatureMoveInput,
|
||||
RoadmapFeatureMoveResult,
|
||||
RoadmapFeatureReorderInput,
|
||||
RoadmapMilestone,
|
||||
RoadmapMilestoneReorderInput,
|
||||
} from "./roadmap-types.js";
|
||||
|
||||
/**
|
||||
* Pure ordering helpers for the standalone roadmap model.
|
||||
*
|
||||
* Ordering invariants enforced by this module:
|
||||
* - helpers operate on scoped arrays (single roadmap for milestones, single
|
||||
* milestone for feature reorders, source+target milestones for feature moves)
|
||||
* - normalized `orderIndex` values are always contiguous + 0-based
|
||||
* - when stored order data is conflicting, helpers repair deterministically via
|
||||
* `orderIndex ASC`, `createdAt ASC`, `id ASC`
|
||||
* - explicit reorder helpers reject partial or duplicate ID lists instead of
|
||||
* guessing user intent
|
||||
*/
|
||||
|
||||
interface OrderedEntity {
|
||||
id: string;
|
||||
orderIndex: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function compareOrderedEntities<T extends OrderedEntity>(a: T, b: T): number {
|
||||
if (a.orderIndex !== b.orderIndex) {
|
||||
return a.orderIndex - b.orderIndex;
|
||||
}
|
||||
|
||||
if (a.createdAt !== b.createdAt) {
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
function clampInsertionIndex(targetIndex: number, length: number): number {
|
||||
if (!Number.isFinite(targetIndex)) {
|
||||
return length;
|
||||
}
|
||||
|
||||
const normalized = Math.trunc(targetIndex);
|
||||
if (normalized < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (normalized > length) {
|
||||
return length;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertScopedRoadmapMilestones(
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
roadmapId: string,
|
||||
): void {
|
||||
for (const milestone of milestones) {
|
||||
if (milestone.roadmapId !== roadmapId) {
|
||||
throw new Error(
|
||||
`Milestone ${milestone.id} does not belong to roadmap ${roadmapId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertScopedMilestoneFeatures(
|
||||
features: readonly RoadmapFeature[],
|
||||
milestoneId: string,
|
||||
): void {
|
||||
for (const feature of features) {
|
||||
if (feature.milestoneId !== milestoneId) {
|
||||
throw new Error(
|
||||
`Feature ${feature.id} does not belong to milestone ${milestoneId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertScopedMoveFeatures(
|
||||
features: readonly RoadmapFeature[],
|
||||
fromMilestoneId: string,
|
||||
toMilestoneId: string,
|
||||
): void {
|
||||
const validMilestoneIds = new Set([fromMilestoneId, toMilestoneId]);
|
||||
|
||||
for (const feature of features) {
|
||||
if (!validMilestoneIds.has(feature.milestoneId)) {
|
||||
throw new Error(
|
||||
`Feature ${feature.id} is outside the affected milestone scope (${fromMilestoneId} → ${toMilestoneId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactIdSet(
|
||||
entityLabel: string,
|
||||
actualIds: readonly string[],
|
||||
orderedIds: readonly string[],
|
||||
): void {
|
||||
const requestedIds = new Set<string>();
|
||||
|
||||
for (const id of orderedIds) {
|
||||
if (requestedIds.has(id)) {
|
||||
throw new Error(`Duplicate ${entityLabel} id in requested order: ${id}`);
|
||||
}
|
||||
requestedIds.add(id);
|
||||
}
|
||||
|
||||
if (actualIds.length !== orderedIds.length) {
|
||||
throw new Error(
|
||||
`Expected ${actualIds.length} ${entityLabel} ids but received ${orderedIds.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const actualIdSet = new Set(actualIds);
|
||||
|
||||
for (const id of orderedIds) {
|
||||
if (!actualIdSet.has(id)) {
|
||||
throw new Error(`${capitalize(entityLabel)} ${id} not found in scoped list`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of actualIds) {
|
||||
if (!requestedIds.has(id)) {
|
||||
throw new Error(`Missing ${entityLabel} id in requested order: ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function assignContiguousOrder<T extends OrderedEntity>(items: readonly T[]): T[] {
|
||||
return items.map((item, orderIndex) => {
|
||||
if (item.orderIndex === orderIndex) {
|
||||
return { ...item };
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
orderIndex,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs milestone ordering for a single roadmap scope.
|
||||
*
|
||||
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||
*/
|
||||
export function normalizeRoadmapMilestoneOrder(
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
): RoadmapMilestone[] {
|
||||
if (milestones.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
assertScopedRoadmapMilestones(milestones, milestones[0].roadmapId);
|
||||
|
||||
return assignContiguousOrder(
|
||||
[...milestones].sort(compareOrderedEntities),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an explicit milestone reorder for a single roadmap scope.
|
||||
*
|
||||
* The caller must provide the complete milestone ID set exactly once. Partial
|
||||
* or duplicate lists are rejected to keep reorders deterministic.
|
||||
*/
|
||||
export function applyRoadmapMilestoneReorder(
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
input: RoadmapMilestoneReorderInput,
|
||||
): RoadmapMilestone[] {
|
||||
assertScopedRoadmapMilestones(milestones, input.roadmapId);
|
||||
|
||||
const normalized = normalizeRoadmapMilestoneOrder(milestones);
|
||||
const ids = normalized.map((milestone) => milestone.id);
|
||||
assertExactIdSet("milestone", ids, input.orderedMilestoneIds);
|
||||
|
||||
const byId = new Map(normalized.map((milestone) => [milestone.id, milestone]));
|
||||
return assignContiguousOrder(
|
||||
input.orderedMilestoneIds.map((id) => byId.get(id)!),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs feature ordering for a single milestone scope.
|
||||
*
|
||||
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||
*/
|
||||
export function normalizeRoadmapFeatureOrder(
|
||||
features: readonly RoadmapFeature[],
|
||||
): RoadmapFeature[] {
|
||||
if (features.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
assertScopedMilestoneFeatures(features, features[0].milestoneId);
|
||||
|
||||
return assignContiguousOrder(
|
||||
[...features].sort(compareOrderedEntities),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an explicit feature reorder for a single milestone scope.
|
||||
*
|
||||
* The caller must provide the complete feature ID set exactly once. Partial or
|
||||
* duplicate lists are rejected to keep reorder behavior deterministic.
|
||||
*/
|
||||
export function applyRoadmapFeatureReorder(
|
||||
features: readonly RoadmapFeature[],
|
||||
input: RoadmapFeatureReorderInput,
|
||||
): RoadmapFeature[] {
|
||||
assertScopedMilestoneFeatures(features, input.milestoneId);
|
||||
|
||||
const normalized = normalizeRoadmapFeatureOrder(features);
|
||||
const ids = normalized.map((feature) => feature.id);
|
||||
assertExactIdSet("feature", ids, input.orderedFeatureIds);
|
||||
|
||||
const byId = new Map(normalized.map((feature) => [feature.id, feature]));
|
||||
return assignContiguousOrder(
|
||||
input.orderedFeatureIds.map((id) => byId.get(id)!),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a feature within the affected milestone scope and deterministically
|
||||
* normalizes both source and destination order.
|
||||
*
|
||||
* For cross-milestone moves, pass the combined feature list from the source and
|
||||
* target milestones. For within-milestone moves, pass the current milestone's
|
||||
* feature list. `targetOrderIndex` is clamped into the destination range.
|
||||
*/
|
||||
export function moveRoadmapFeature(
|
||||
features: readonly RoadmapFeature[],
|
||||
input: RoadmapFeatureMoveInput,
|
||||
): RoadmapFeatureMoveResult {
|
||||
assertScopedMoveFeatures(features, input.fromMilestoneId, input.toMilestoneId);
|
||||
|
||||
const existingFeature = features.find((feature) => feature.id === input.featureId);
|
||||
if (!existingFeature) {
|
||||
throw new Error(`Feature ${input.featureId} not found in affected milestone scope`);
|
||||
}
|
||||
|
||||
if (existingFeature.milestoneId !== input.fromMilestoneId) {
|
||||
throw new Error(
|
||||
`Feature ${input.featureId} does not belong to milestone ${input.fromMilestoneId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceFeatures = normalizeRoadmapFeatureOrder(
|
||||
features.filter((feature) => feature.milestoneId === input.fromMilestoneId),
|
||||
);
|
||||
const sourceWithoutFeature = sourceFeatures.filter(
|
||||
(feature) => feature.id !== input.featureId,
|
||||
);
|
||||
|
||||
if (input.fromMilestoneId === input.toMilestoneId) {
|
||||
const insertionIndex = clampInsertionIndex(
|
||||
input.targetOrderIndex,
|
||||
sourceWithoutFeature.length,
|
||||
);
|
||||
const reordered = [...sourceWithoutFeature];
|
||||
reordered.splice(insertionIndex, 0, {
|
||||
...existingFeature,
|
||||
milestoneId: input.toMilestoneId,
|
||||
orderIndex: insertionIndex,
|
||||
});
|
||||
|
||||
const normalized = assignContiguousOrder(reordered);
|
||||
const movedFeature = normalized.find((feature) => feature.id === input.featureId)!;
|
||||
|
||||
return {
|
||||
movedFeature,
|
||||
affectedFeatures: normalized,
|
||||
sourceMilestoneFeatures: normalized,
|
||||
targetMilestoneFeatures: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
const targetFeatures = normalizeRoadmapFeatureOrder(
|
||||
features.filter((feature) => feature.milestoneId === input.toMilestoneId),
|
||||
);
|
||||
const insertionIndex = clampInsertionIndex(
|
||||
input.targetOrderIndex,
|
||||
targetFeatures.length,
|
||||
);
|
||||
const targetWithInsertedFeature = [...targetFeatures];
|
||||
targetWithInsertedFeature.splice(insertionIndex, 0, {
|
||||
...existingFeature,
|
||||
milestoneId: input.toMilestoneId,
|
||||
orderIndex: insertionIndex,
|
||||
});
|
||||
|
||||
const normalizedSource = assignContiguousOrder(sourceWithoutFeature);
|
||||
const normalizedTarget = assignContiguousOrder(targetWithInsertedFeature);
|
||||
const movedFeature = normalizedTarget.find((feature) => feature.id === input.featureId)!;
|
||||
|
||||
return {
|
||||
movedFeature,
|
||||
affectedFeatures: [...normalizedSource, ...normalizedTarget],
|
||||
sourceMilestoneFeatures: normalizedSource,
|
||||
targetMilestoneFeatures: normalizedTarget,
|
||||
};
|
||||
}
|
||||
@@ -1,960 +0,0 @@
|
||||
/**
|
||||
* RoadmapStore - Data layer for standalone roadmap persistence.
|
||||
*
|
||||
* Manages CRUD operations for roadmaps, milestones, and features.
|
||||
* Provides deterministic ordering via covering indexes and atomic reorder/move operations.
|
||||
*
|
||||
* Ordering invariants:
|
||||
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
|
||||
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
|
||||
* - all list/read queries use deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||
* - cross-milestone feature moves atomically renumber both affected milestone scopes
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Database } from "./db.js";
|
||||
import type {
|
||||
Roadmap,
|
||||
RoadmapMilestone,
|
||||
RoadmapFeature,
|
||||
RoadmapCreateInput,
|
||||
RoadmapUpdateInput,
|
||||
RoadmapMilestoneCreateInput,
|
||||
RoadmapMilestoneUpdateInput,
|
||||
RoadmapFeatureCreateInput,
|
||||
RoadmapFeatureUpdateInput,
|
||||
RoadmapMilestoneReorderInput,
|
||||
RoadmapFeatureReorderInput,
|
||||
RoadmapFeatureMoveInput,
|
||||
RoadmapMilestoneWithFeatures,
|
||||
RoadmapWithHierarchy,
|
||||
RoadmapExportBundle,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
RoadmapFeatureSourceRef,
|
||||
} from "./roadmap-types.js";
|
||||
import {
|
||||
applyRoadmapMilestoneReorder,
|
||||
applyRoadmapFeatureReorder,
|
||||
moveRoadmapFeature,
|
||||
} from "./roadmap-ordering.js";
|
||||
|
||||
// ── Event Types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface RoadmapStoreEvents {
|
||||
/** Emitted when a roadmap is created */
|
||||
"roadmap:created": [Roadmap];
|
||||
/** Emitted when a roadmap is updated */
|
||||
"roadmap:updated": [Roadmap];
|
||||
/** Emitted when a roadmap is deleted */
|
||||
"roadmap:deleted": [string];
|
||||
/** Emitted when a milestone is created */
|
||||
"milestone:created": [RoadmapMilestone];
|
||||
/** Emitted when a milestone is updated */
|
||||
"milestone:updated": [RoadmapMilestone];
|
||||
/** Emitted when a milestone is deleted */
|
||||
"milestone:deleted": [string];
|
||||
/** Emitted when a milestone is reordered */
|
||||
"milestone:reordered": [{ roadmapId: string; milestones: RoadmapMilestone[] }];
|
||||
/** Emitted when a feature is created */
|
||||
"feature:created": [RoadmapFeature];
|
||||
/** Emitted when a feature is updated */
|
||||
"feature:updated": [RoadmapFeature];
|
||||
/** Emitted when a feature is deleted */
|
||||
"feature:deleted": [RoadmapFeature];
|
||||
/** Emitted when features are reordered within a milestone */
|
||||
"feature:reordered": [{ milestoneId: string; features: RoadmapFeature[] }];
|
||||
/** Emitted when a feature is moved (including cross-milestone moves) */
|
||||
"feature:moved": [{ feature: RoadmapFeature; fromMilestoneId: string; toMilestoneId: string }];
|
||||
}
|
||||
|
||||
// ── Row Interfaces ──────────────────────────────────────────────────
|
||||
|
||||
/** Database row shape for roadmaps. */
|
||||
interface RoadmapRow {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Database row shape for roadmap_milestones. */
|
||||
interface RoadmapMilestoneRow {
|
||||
id: string;
|
||||
roadmapId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
orderIndex: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Database row shape for roadmap_features. */
|
||||
interface RoadmapFeatureRow {
|
||||
id: string;
|
||||
milestoneId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
orderIndex: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── RoadmapStore Class ──────────────────────────────────────────────
|
||||
|
||||
export class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
|
||||
/**
|
||||
* Creates a new RoadmapStore instance.
|
||||
*
|
||||
* @param db - Shared Database instance (same instance used by TaskStore)
|
||||
*/
|
||||
constructor(private db: Database) {
|
||||
super();
|
||||
this.setMaxListeners(50);
|
||||
}
|
||||
|
||||
// ── ID Generators ───────────────────────────────────────────────────
|
||||
|
||||
private generateRoadmapId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `RM-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
|
||||
private generateMilestoneId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `RMS-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
|
||||
private generateFeatureId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `RF-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
|
||||
// ── Row-to-Object Converters ───────────────────────────────────────
|
||||
|
||||
private rowToRoadmap(row: RoadmapRow): Roadmap {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description || undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private rowToMilestone(row: RoadmapMilestoneRow): RoadmapMilestone {
|
||||
return {
|
||||
id: row.id,
|
||||
roadmapId: row.roadmapId,
|
||||
title: row.title,
|
||||
description: row.description || undefined,
|
||||
orderIndex: row.orderIndex,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private rowToFeature(row: RoadmapFeatureRow): RoadmapFeature {
|
||||
return {
|
||||
id: row.id,
|
||||
milestoneId: row.milestoneId,
|
||||
title: row.title,
|
||||
description: row.description || undefined,
|
||||
orderIndex: row.orderIndex,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Roadmap CRUD ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new roadmap.
|
||||
*
|
||||
* @param input - Roadmap creation input
|
||||
* @returns The created roadmap
|
||||
*/
|
||||
createRoadmap(input: RoadmapCreateInput): Roadmap {
|
||||
const now = new Date().toISOString();
|
||||
const id = this.generateRoadmapId();
|
||||
|
||||
const roadmap: Roadmap = {
|
||||
id,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO roadmaps (id, title, description, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
roadmap.id,
|
||||
roadmap.title,
|
||||
roadmap.description ?? null,
|
||||
roadmap.createdAt,
|
||||
roadmap.updatedAt,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("roadmap:created", roadmap);
|
||||
return roadmap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a roadmap by ID.
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @returns The roadmap, or undefined if not found
|
||||
*/
|
||||
getRoadmap(id: string): Roadmap | undefined {
|
||||
const row = this.db.prepare("SELECT * FROM roadmaps WHERE id = ?").get(id) as unknown as RoadmapRow | undefined;
|
||||
if (!row) return undefined;
|
||||
return this.rowToRoadmap(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all roadmaps, ordered by creation date (newest first).
|
||||
*
|
||||
* @returns Array of roadmaps
|
||||
*/
|
||||
listRoadmaps(): Roadmap[] {
|
||||
const rows = this.db.prepare(
|
||||
"SELECT * FROM roadmaps ORDER BY createdAt DESC"
|
||||
).all();
|
||||
return (rows as unknown as RoadmapRow[]).map((row) => this.rowToRoadmap(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a roadmap.
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @param updates - Partial roadmap updates
|
||||
* @returns The updated roadmap
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
updateRoadmap(id: string, updates: RoadmapUpdateInput): Roadmap {
|
||||
const roadmap = this.getRoadmap(id);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${id} not found`);
|
||||
}
|
||||
|
||||
const updated: Roadmap = {
|
||||
...roadmap,
|
||||
...updates,
|
||||
id, // Prevent changing ID
|
||||
createdAt: roadmap.createdAt, // Prevent changing creation time
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
UPDATE roadmaps SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
updated.title,
|
||||
updated.description ?? null,
|
||||
updated.updatedAt,
|
||||
updated.id,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("roadmap:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a roadmap and all its milestones/features (cascading).
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
deleteRoadmap(id: string): void {
|
||||
const roadmap = this.getRoadmap(id);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${id} not found`);
|
||||
}
|
||||
|
||||
// SQLite FK cascade will handle milestones and features
|
||||
this.db.prepare("DELETE FROM roadmaps WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
this.emit("roadmap:deleted", id);
|
||||
}
|
||||
|
||||
// ── Milestone CRUD ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a milestone to a roadmap.
|
||||
* Automatically computes the orderIndex (max + 1).
|
||||
*
|
||||
* @param roadmapId - Parent roadmap ID
|
||||
* @param input - Milestone creation input
|
||||
* @returns The created milestone
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
createMilestone(roadmapId: string, input: RoadmapMilestoneCreateInput): RoadmapMilestone {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const id = this.generateMilestoneId();
|
||||
|
||||
// Compute next orderIndex
|
||||
const existingMilestones = this.listMilestones(roadmapId);
|
||||
const orderIndex = existingMilestones.length > 0
|
||||
? Math.max(...existingMilestones.map((m) => m.orderIndex)) + 1
|
||||
: 0;
|
||||
|
||||
const milestone: RoadmapMilestone = {
|
||||
id,
|
||||
roadmapId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
orderIndex,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO roadmap_milestones (id, roadmapId, title, description, orderIndex, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
milestone.id,
|
||||
milestone.roadmapId,
|
||||
milestone.title,
|
||||
milestone.description ?? null,
|
||||
milestone.orderIndex,
|
||||
milestone.createdAt,
|
||||
milestone.updatedAt,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("milestone:created", milestone);
|
||||
return milestone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a milestone by ID.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @returns The milestone, or undefined if not found
|
||||
*/
|
||||
getMilestone(id: string): RoadmapMilestone | undefined {
|
||||
const row = this.db.prepare("SELECT * FROM roadmap_milestones WHERE id = ?").get(id) as unknown as RoadmapMilestoneRow | undefined;
|
||||
if (!row) return undefined;
|
||||
return this.rowToMilestone(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* List milestones for a roadmap, ordered deterministically.
|
||||
*
|
||||
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||
* to ensure consistent results when stored order data is incomplete or conflicting.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns Array of milestones in deterministic order
|
||||
*/
|
||||
listMilestones(roadmapId: string): RoadmapMilestone[] {
|
||||
const rows = this.db.prepare(
|
||||
"SELECT * FROM roadmap_milestones WHERE roadmapId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC"
|
||||
).all(roadmapId);
|
||||
return (rows as unknown as RoadmapMilestoneRow[]).map((row) => this.rowToMilestone(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a milestone.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @param updates - Partial milestone updates
|
||||
* @returns The updated milestone
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
updateMilestone(id: string, updates: RoadmapMilestoneUpdateInput): RoadmapMilestone {
|
||||
const milestone = this.getMilestone(id);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${id} not found`);
|
||||
}
|
||||
|
||||
const updated: RoadmapMilestone = {
|
||||
...milestone,
|
||||
...updates,
|
||||
id, // Prevent changing ID
|
||||
roadmapId: milestone.roadmapId, // Prevent moving to different roadmap
|
||||
createdAt: milestone.createdAt, // Prevent changing creation time
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_milestones SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
updated.title,
|
||||
updated.description ?? null,
|
||||
updated.updatedAt,
|
||||
updated.id,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("milestone:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a milestone and all its features (cascading).
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
deleteMilestone(id: string): void {
|
||||
const milestone = this.getMilestone(id);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${id} not found`);
|
||||
}
|
||||
|
||||
// SQLite FK cascade will handle features
|
||||
this.db.prepare("DELETE FROM roadmap_milestones WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
this.emit("milestone:deleted", id);
|
||||
}
|
||||
|
||||
// ── Feature CRUD ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a feature to a milestone.
|
||||
* Automatically computes the orderIndex (max + 1).
|
||||
*
|
||||
* @param milestoneId - Parent milestone ID
|
||||
* @param input - Feature creation input
|
||||
* @returns The created feature
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
createFeature(milestoneId: string, input: RoadmapFeatureCreateInput): RoadmapFeature {
|
||||
const milestone = this.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${milestoneId} not found`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const id = this.generateFeatureId();
|
||||
|
||||
// Compute next orderIndex
|
||||
const existingFeatures = this.listFeatures(milestoneId);
|
||||
const orderIndex = existingFeatures.length > 0
|
||||
? Math.max(...existingFeatures.map((f) => f.orderIndex)) + 1
|
||||
: 0;
|
||||
|
||||
const feature: RoadmapFeature = {
|
||||
id,
|
||||
milestoneId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
orderIndex,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO roadmap_features (id, milestoneId, title, description, orderIndex, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
feature.id,
|
||||
feature.milestoneId,
|
||||
feature.title,
|
||||
feature.description ?? null,
|
||||
feature.orderIndex,
|
||||
feature.createdAt,
|
||||
feature.updatedAt,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("feature:created", feature);
|
||||
return feature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a feature by ID.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @returns The feature, or undefined if not found
|
||||
*/
|
||||
getFeature(id: string): RoadmapFeature | undefined {
|
||||
const row = this.db.prepare("SELECT * FROM roadmap_features WHERE id = ?").get(id) as unknown as RoadmapFeatureRow | undefined;
|
||||
if (!row) return undefined;
|
||||
return this.rowToFeature(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* List features for a milestone, ordered deterministically.
|
||||
*
|
||||
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||
* to ensure consistent results when stored order data is incomplete or conflicting.
|
||||
*
|
||||
* @param milestoneId - Milestone ID
|
||||
* @returns Array of features in deterministic order
|
||||
*/
|
||||
listFeatures(milestoneId: string): RoadmapFeature[] {
|
||||
const rows = this.db.prepare(
|
||||
"SELECT * FROM roadmap_features WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC"
|
||||
).all(milestoneId);
|
||||
return (rows as unknown as RoadmapFeatureRow[]).map((row) => this.rowToFeature(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a feature.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @param updates - Partial feature updates
|
||||
* @returns The updated feature
|
||||
* @throws Error if feature not found
|
||||
*/
|
||||
updateFeature(id: string, updates: RoadmapFeatureUpdateInput): RoadmapFeature {
|
||||
const feature = this.getFeature(id);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${id} not found`);
|
||||
}
|
||||
|
||||
const updated: RoadmapFeature = {
|
||||
...feature,
|
||||
...updates,
|
||||
id, // Prevent changing ID
|
||||
milestoneId: feature.milestoneId, // Prevent moving via update (use moveFeature instead)
|
||||
createdAt: feature.createdAt, // Prevent changing creation time
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_features SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
updated.title,
|
||||
updated.description ?? null,
|
||||
updated.updatedAt,
|
||||
updated.id,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("feature:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a feature.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @throws Error if feature not found
|
||||
*/
|
||||
deleteFeature(id: string): void {
|
||||
const feature = this.getFeature(id);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${id} not found`);
|
||||
}
|
||||
|
||||
this.db.prepare("DELETE FROM roadmap_features WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
this.emit("feature:deleted", feature);
|
||||
}
|
||||
|
||||
// ── Reorder Operations ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reorder milestones within a roadmap.
|
||||
*
|
||||
* Applies an explicit reorder input and persists the full normalized order.
|
||||
* The input must contain all milestone IDs exactly once.
|
||||
*
|
||||
* @param input - Reorder input with complete milestone ID list
|
||||
* @returns The reordered milestones in their new order
|
||||
* @throws Error if milestone set is incomplete, duplicate, or not found
|
||||
*/
|
||||
reorderMilestones(input: RoadmapMilestoneReorderInput): RoadmapMilestone[] {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(input.roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${input.roadmapId} not found`);
|
||||
}
|
||||
|
||||
// Load current milestones with deterministic ordering
|
||||
const milestones = this.listMilestones(input.roadmapId);
|
||||
|
||||
// Apply the reorder using the pure ordering helper
|
||||
const reordered = applyRoadmapMilestoneReorder(milestones, input);
|
||||
|
||||
// Persist in a transaction
|
||||
this.db.transaction(() => {
|
||||
for (const milestone of reordered) {
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_milestones SET orderIndex = ?, updatedAt = ? WHERE id = ?
|
||||
`).run(milestone.orderIndex, new Date().toISOString(), milestone.id);
|
||||
}
|
||||
});
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("milestone:reordered", { roadmapId: input.roadmapId, milestones: reordered });
|
||||
|
||||
return reordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder features within a milestone.
|
||||
*
|
||||
* Applies an explicit reorder input and persists the full normalized order.
|
||||
* The input must contain all feature IDs for the milestone exactly once.
|
||||
*
|
||||
* @param input - Reorder input with complete feature ID list
|
||||
* @returns The reordered features in their new order
|
||||
* @throws Error if feature set is incomplete, duplicate, or not found
|
||||
*/
|
||||
reorderFeatures(input: RoadmapFeatureReorderInput): RoadmapFeature[] {
|
||||
// Validate milestone exists and belongs to the roadmap
|
||||
const milestone = this.getMilestone(input.milestoneId);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${input.milestoneId} not found`);
|
||||
}
|
||||
if (milestone.roadmapId !== input.roadmapId) {
|
||||
throw new Error(`Milestone ${input.milestoneId} does not belong to roadmap ${input.roadmapId}`);
|
||||
}
|
||||
|
||||
// Load current features with deterministic ordering
|
||||
const features = this.listFeatures(input.milestoneId);
|
||||
|
||||
// Apply the reorder using the pure ordering helper
|
||||
const reordered = applyRoadmapFeatureReorder(features, input);
|
||||
|
||||
// Persist in a transaction
|
||||
this.db.transaction(() => {
|
||||
for (const feature of reordered) {
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_features SET orderIndex = ?, updatedAt = ? WHERE id = ?
|
||||
`).run(feature.orderIndex, new Date().toISOString(), feature.id);
|
||||
}
|
||||
});
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("feature:reordered", { milestoneId: input.milestoneId, features: reordered });
|
||||
|
||||
return reordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a feature, including cross-milestone moves.
|
||||
*
|
||||
* Atomically renumbers both the source and destination milestone scopes.
|
||||
*
|
||||
* @param input - Move input with source/destination milestone info
|
||||
* @returns The moved feature and both affected milestone feature lists
|
||||
* @throws Error if feature or milestone not found, or scope validation fails
|
||||
*/
|
||||
moveFeature(input: RoadmapFeatureMoveInput): {
|
||||
movedFeature: RoadmapFeature;
|
||||
sourceMilestoneFeatures: RoadmapFeature[];
|
||||
targetMilestoneFeatures: RoadmapFeature[];
|
||||
} {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(input.roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${input.roadmapId} not found`);
|
||||
}
|
||||
|
||||
// Validate both milestones exist and belong to the roadmap
|
||||
const fromMilestone = this.getMilestone(input.fromMilestoneId);
|
||||
const toMilestone = this.getMilestone(input.toMilestoneId);
|
||||
|
||||
if (!fromMilestone) {
|
||||
throw new Error(`Source milestone ${input.fromMilestoneId} not found`);
|
||||
}
|
||||
if (!toMilestone) {
|
||||
throw new Error(`Destination milestone ${input.toMilestoneId} not found`);
|
||||
}
|
||||
if (fromMilestone.roadmapId !== input.roadmapId) {
|
||||
throw new Error(`Source milestone ${input.fromMilestoneId} does not belong to roadmap ${input.roadmapId}`);
|
||||
}
|
||||
if (toMilestone.roadmapId !== input.roadmapId) {
|
||||
throw new Error(`Destination milestone ${input.toMilestoneId} does not belong to roadmap ${input.roadmapId}`);
|
||||
}
|
||||
|
||||
// Load features from both milestones with deterministic ordering
|
||||
const sourceFeatures = this.listFeatures(input.fromMilestoneId);
|
||||
const targetFeatures = this.listFeatures(input.toMilestoneId);
|
||||
|
||||
// For same-milestone moves, pass only one list to avoid duplication
|
||||
// For cross-milestone moves, pass the combined list
|
||||
const allFeatures = input.fromMilestoneId === input.toMilestoneId
|
||||
? sourceFeatures
|
||||
: [...sourceFeatures, ...targetFeatures];
|
||||
|
||||
// Apply the move using the pure ordering helper
|
||||
const result = moveRoadmapFeature(allFeatures, input);
|
||||
|
||||
// Persist in a transaction
|
||||
this.db.transaction(() => {
|
||||
// Update all affected features
|
||||
for (const feature of result.affectedFeatures) {
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_features SET milestoneId = ?, orderIndex = ?, updatedAt = ? WHERE id = ?
|
||||
`).run(feature.milestoneId, feature.orderIndex, new Date().toISOString(), feature.id);
|
||||
}
|
||||
});
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("feature:moved", {
|
||||
feature: result.movedFeature,
|
||||
fromMilestoneId: input.fromMilestoneId,
|
||||
toMilestoneId: input.toMilestoneId,
|
||||
});
|
||||
|
||||
return {
|
||||
movedFeature: result.movedFeature,
|
||||
sourceMilestoneFeatures: result.sourceMilestoneFeatures,
|
||||
targetMilestoneFeatures: result.targetMilestoneFeatures,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Hierarchy Operations ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a milestone with all of its features in deterministic order.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @returns The milestone with features, or undefined if not found
|
||||
*/
|
||||
getMilestoneWithFeatures(id: string): RoadmapMilestoneWithFeatures | undefined {
|
||||
const milestone = this.getMilestone(id);
|
||||
if (!milestone) return undefined;
|
||||
|
||||
return {
|
||||
...milestone,
|
||||
features: this.listFeatures(id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a roadmap with its full hierarchy (milestones → features).
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @returns The roadmap with hierarchy, or undefined if not found
|
||||
*/
|
||||
getRoadmapWithHierarchy(id: string): RoadmapWithHierarchy | undefined {
|
||||
const roadmap = this.getRoadmap(id);
|
||||
if (!roadmap) return undefined;
|
||||
|
||||
return {
|
||||
...roadmap,
|
||||
milestones: this.listMilestones(id).map((milestone) => ({
|
||||
...milestone,
|
||||
features: this.listFeatures(milestone.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Export / Handoff Operations ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a flat export bundle for a roadmap.
|
||||
*
|
||||
* Returns all roadmap data in a flat structure suitable for persistence,
|
||||
* APIs, import/export, and sync jobs. Entities are separated so downstream
|
||||
* persistence layers can upsert by table/collection.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The export bundle with ordered entities
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getRoadmapExport(roadmapId: string): RoadmapExportBundle {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
const allFeatures: RoadmapFeature[] = [];
|
||||
|
||||
for (const milestone of milestones) {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
allFeatures.push(...features);
|
||||
}
|
||||
|
||||
return {
|
||||
roadmap,
|
||||
milestones,
|
||||
features: allFeatures,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mission planning handoff payload for a roadmap.
|
||||
*
|
||||
* Converts the roadmap into a mission planning structure while preserving
|
||||
* source IDs and deterministic order. Does not couple to MissionStore internals.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The mission planning handoff payload
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getRoadmapMissionHandoff(roadmapId: string): RoadmapMissionPlanningHandoff {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
|
||||
return {
|
||||
sourceRoadmapId: roadmap.id,
|
||||
title: roadmap.title,
|
||||
description: roadmap.description,
|
||||
milestones: milestones.map((milestone) => {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
|
||||
return {
|
||||
sourceMilestoneId: milestone.id,
|
||||
title: milestone.title,
|
||||
description: milestone.description,
|
||||
orderIndex: milestone.orderIndex,
|
||||
features: features.map((feature) => ({
|
||||
sourceFeatureId: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
orderIndex: feature.orderIndex,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a task planning handoff payload for a single roadmap feature.
|
||||
*
|
||||
* Returns a self-contained handoff payload for converting a roadmap feature
|
||||
* into task planning flows without coupling to MissionStore internals.
|
||||
*
|
||||
* @param roadmapId - Parent roadmap ID (for validation)
|
||||
* @param milestoneId - Parent milestone ID (for validation)
|
||||
* @param featureId - Feature ID to generate handoff for
|
||||
* @returns The task planning handoff payload
|
||||
* @throws Error if any entity is not found or if ownership validation fails
|
||||
*/
|
||||
getRoadmapFeatureHandoff(
|
||||
roadmapId: string,
|
||||
milestoneId: string,
|
||||
featureId: string,
|
||||
): RoadmapFeatureTaskPlanningHandoff {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
// Validate milestone exists and belongs to roadmap
|
||||
const milestone = this.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${milestoneId} not found`);
|
||||
}
|
||||
if (milestone.roadmapId !== roadmapId) {
|
||||
throw new Error(`Milestone ${milestoneId} does not belong to roadmap ${roadmapId}`);
|
||||
}
|
||||
|
||||
// Validate feature exists and belongs to milestone
|
||||
const feature = this.getFeature(featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
if (feature.milestoneId !== milestoneId) {
|
||||
throw new Error(`Feature ${featureId} does not belong to milestone ${milestoneId}`);
|
||||
}
|
||||
|
||||
// Build the source reference with ordering context
|
||||
const source: RoadmapFeatureSourceRef = {
|
||||
roadmapId: roadmap.id,
|
||||
milestoneId: milestone.id,
|
||||
featureId: feature.id,
|
||||
roadmapTitle: roadmap.title,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneOrderIndex: milestone.orderIndex,
|
||||
featureOrderIndex: feature.orderIndex,
|
||||
};
|
||||
|
||||
return {
|
||||
source,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mission planning handoff payload for a roadmap.
|
||||
*
|
||||
* Alias for getRoadmapMissionHandoff() for API consistency.
|
||||
* Converts the roadmap into a mission planning structure while preserving
|
||||
* source IDs and deterministic order.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The mission planning handoff payload
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getMissionPlanningHandoff(roadmapId: string): RoadmapMissionPlanningHandoff {
|
||||
return this.getRoadmapMissionHandoff(roadmapId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all task planning handoff payloads for a roadmap.
|
||||
*
|
||||
* Returns a flat list of all feature handoffs in deterministic order
|
||||
* (milestone order index, then feature order index).
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns Array of task planning handoff payloads for all features
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
listFeatureTaskPlanningHandoffs(roadmapId: string): RoadmapFeatureTaskPlanningHandoff[] {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
const handoffs: RoadmapFeatureTaskPlanningHandoff[] = [];
|
||||
|
||||
for (const milestone of milestones) {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
|
||||
for (const feature of features) {
|
||||
const source: RoadmapFeatureSourceRef = {
|
||||
roadmapId: roadmap.id,
|
||||
milestoneId: milestone.id,
|
||||
featureId: feature.id,
|
||||
roadmapTitle: roadmap.title,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneOrderIndex: milestone.orderIndex,
|
||||
featureOrderIndex: feature.orderIndex,
|
||||
};
|
||||
|
||||
handoffs.push({
|
||||
source,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return handoffs;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { ArchiveDatabase } from "./archive-db.js";
|
||||
import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js";
|
||||
import { MissionStore } from "./mission-store.js";
|
||||
import { PluginStore } from "./plugin-store.js";
|
||||
import { RoadmapStore } from "./roadmap-store.js";
|
||||
import { InsightStore } from "./insight-store.js";
|
||||
import { ResearchStore } from "./research-store.js";
|
||||
import { TodoStore } from "./todo-store.js";
|
||||
@@ -513,8 +512,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
private missionStore: MissionStore | null = null;
|
||||
/** Cached PluginStore instance */
|
||||
private pluginStore: PluginStore | null = null;
|
||||
/** Cached RoadmapStore instance */
|
||||
private roadmapStore: RoadmapStore | null = null;
|
||||
/** Cached InsightStore instance */
|
||||
private insightStore: InsightStore | null = null;
|
||||
/** Cached ResearchStore instance */
|
||||
@@ -6813,17 +6810,6 @@ ${notificationsSection}`;
|
||||
return this.pluginStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the RoadmapStore instance for standalone roadmap operations.
|
||||
* Lazily initializes the RoadmapStore on first access.
|
||||
*/
|
||||
getRoadmapStore(): RoadmapStore {
|
||||
if (!this.roadmapStore) {
|
||||
this.roadmapStore = new RoadmapStore(this.db);
|
||||
}
|
||||
return this.roadmapStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the InsightStore instance for project insights operations.
|
||||
* Lazily initializes the InsightStore on first access.
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@f
|
||||
|
||||
|
||||
// vi.mock is hoisted
|
||||
vi.mock("../roadmap-suggestions.js", () => {
|
||||
vi.mock("../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js", () => {
|
||||
// Define error classes inside the factory - these will be used by the mocked module
|
||||
class MockValidationError extends Error { name = "ValidationError"; constructor(m: string) { super(m); } }
|
||||
class MockParseError extends Error { name = "ParseError"; constructor(m: string) { super(m); } }
|
||||
@@ -453,11 +453,11 @@ describe("Roadmap Routes", () => {
|
||||
});
|
||||
|
||||
describe("projectId scoping", () => {
|
||||
it("uses projectId from query param", async () => {
|
||||
it("ignores projectId query param in legacy adapter", async () => {
|
||||
mockRoadmapStore.createRoadmap({ title: "Project Roadmap" });
|
||||
const response = await performGet(app, "/api/roadmaps?projectId=test-project");
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("test-project");
|
||||
expect(mockGetOrCreateProjectStore).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -576,16 +576,7 @@ describe("Roadmap Routes", () => {
|
||||
});
|
||||
|
||||
describe("POST /api/roadmaps/:roadmapId/suggestions/milestones", () => {
|
||||
it("returns 503 when generation times out", async () => {
|
||||
// Import the mocked module
|
||||
const mod = await import("../roadmap-suggestions.js");
|
||||
|
||||
// Create an instance of the mocked ServiceUnavailableError
|
||||
const error = new mod.ServiceUnavailableError("AI suggestion generation timed out. Please try again.");
|
||||
|
||||
// Mock to throw ServiceUnavailableError with timeout message
|
||||
(mod.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||||
|
||||
it("returns 503 when AI is unavailable", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
|
||||
|
||||
const response = await performRequest(
|
||||
@@ -597,21 +588,12 @@ describe("Roadmap Routes", () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.error).toContain("timed out");
|
||||
expect(response.body.error).toContain("AI service is not available");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/roadmaps/milestones/:milestoneId/suggestions/features", () => {
|
||||
it("returns 503 when generation times out", async () => {
|
||||
// Import the mocked module - vi.mocked helps with type inference
|
||||
const mod = vi.mocked(await import("../roadmap-suggestions.js"));
|
||||
|
||||
// Create an instance of the mocked ServiceUnavailableError
|
||||
const error = new mod.ServiceUnavailableError("AI suggestion generation timed out. Please try again.");
|
||||
|
||||
// Mock to throw ServiceUnavailableError with timeout message
|
||||
mod.generateFeatureSuggestions.mockRejectedValue(error);
|
||||
|
||||
it("returns 503 when AI is unavailable", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
|
||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
|
||||
|
||||
@@ -624,7 +606,7 @@ describe("Roadmap Routes", () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.error).toContain("timed out");
|
||||
expect(response.body.error).toContain("AI service is not available");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,701 +1,91 @@
|
||||
/**
|
||||
* Roadmap REST API Routes
|
||||
*
|
||||
* Provides CRUD endpoints for standalone roadmaps, milestones, and features.
|
||||
* Also includes AI-powered suggestion endpoints for milestone and feature creation,
|
||||
* and read-only handoff endpoints for exporting roadmap data to mission/task planning.
|
||||
*
|
||||
* Endpoints:
|
||||
* - Roadmaps: GET /, POST /, GET /:id, PATCH /:id, DELETE /:id
|
||||
* - Milestones: GET /:roadmapId/milestones, POST /:roadmapId/milestones,
|
||||
* PATCH /milestones/:id, DELETE /milestones/:id,
|
||||
* POST /:roadmapId/milestones/reorder
|
||||
* - Features: GET /milestones/:milestoneId/features,
|
||||
* POST /milestones/:milestoneId/features,
|
||||
* PATCH /features/:id, DELETE /features/:id,
|
||||
* POST /milestones/:milestoneId/features/reorder,
|
||||
* POST /features/:id/move
|
||||
* - Suggestions: POST /:roadmapId/suggestions/milestones,
|
||||
* POST /milestones/:milestoneId/suggestions/features
|
||||
* - Export/Handoff: GET /:roadmapId/export, GET /:roadmapId/handoff,
|
||||
* GET /:roadmapId/handoff/mission,
|
||||
* GET /:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import {
|
||||
ApiError,
|
||||
badRequest,
|
||||
notFound,
|
||||
internalError,
|
||||
} from "./api-error.js";
|
||||
import { getCreateAiSessionFactory, type PluginContext, type PluginRouteDefinition, type TaskStore } from "@fusion/core";
|
||||
import { createRoadmapPluginRoutes } from "../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js";
|
||||
|
||||
/**
|
||||
* Re-throws an error as an ApiError, converting unknown errors to internal errors.
|
||||
* This is used in route handlers to ensure all errors are properly typed.
|
||||
*/
|
||||
function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server error"): never {
|
||||
if (error instanceof ApiError) throw error;
|
||||
if (error instanceof Error) throw new ApiError(500, error.message);
|
||||
throw new ApiError(500, fallbackMessage);
|
||||
function isRouteResponse(value: unknown): value is { status: number; body?: unknown } {
|
||||
return (
|
||||
typeof value === "object"
|
||||
&& value !== null
|
||||
&& "status" in value
|
||||
&& typeof (value as { status?: unknown }).status === "number"
|
||||
);
|
||||
}
|
||||
|
||||
import {
|
||||
generateMilestoneSuggestions,
|
||||
validateSuggestionInput,
|
||||
generateFeatureSuggestions,
|
||||
validateFeatureSuggestionInput,
|
||||
ValidationError as SuggestionValidationError,
|
||||
ParseError as SuggestionParseError,
|
||||
ServiceUnavailableError as SuggestionServiceUnavailableError,
|
||||
SUGGESTION_TIMEOUT_MS,
|
||||
} from "./roadmap-suggestions.js";
|
||||
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
||||
async function buildContext(store: TaskStore): Promise<PluginContext> {
|
||||
const createAiSession = await getCreateAiSessionFactory();
|
||||
|
||||
// ── Validation Utilities ──────────────────────────────────────────────────────
|
||||
|
||||
function validateTitle(title: unknown): string {
|
||||
if (!title || typeof title !== "string" || !title.trim()) {
|
||||
throw badRequest("title is required");
|
||||
}
|
||||
if (title.length > 200) {
|
||||
throw badRequest("title must not exceed 200 characters");
|
||||
}
|
||||
return title.trim();
|
||||
return {
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
taskStore: store,
|
||||
settings: {},
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
},
|
||||
emitEvent: () => {},
|
||||
createAiSession,
|
||||
};
|
||||
}
|
||||
|
||||
function validateDescription(desc: unknown): string | undefined {
|
||||
if (desc === undefined || desc === null) return undefined;
|
||||
if (typeof desc !== "string") {
|
||||
throw badRequest("description must be a string");
|
||||
}
|
||||
if (desc.length > 5000) {
|
||||
throw badRequest("description must not exceed 5000 characters");
|
||||
}
|
||||
return desc.trim() || undefined;
|
||||
}
|
||||
|
||||
function validateStringArray(arr: unknown, fieldName: string): string[] {
|
||||
if (!Array.isArray(arr)) {
|
||||
throw badRequest(`${fieldName} must be an array`);
|
||||
}
|
||||
if (!arr.every((item) => typeof item === "string")) {
|
||||
throw badRequest(`${fieldName} must be an array of strings`);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// ── Router Factory ────────────────────────────────────────────────────────────
|
||||
|
||||
export function createRoadmapRouter(store: TaskStore): Router {
|
||||
const router = Router();
|
||||
const requestContext = new AsyncLocalStorage<TaskStore>();
|
||||
const routes = createRoadmapPluginRoutes();
|
||||
|
||||
function getProjectIdFromRequest(req: Request): string | undefined {
|
||||
if (typeof req.query.projectId === "string" && req.query.projectId.trim()) {
|
||||
return req.query.projectId;
|
||||
}
|
||||
if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string" && req.body.projectId.trim()) {
|
||||
return req.body.projectId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getScopedStore(): TaskStore {
|
||||
const scoped = requestContext.getStore();
|
||||
return scoped ?? store;
|
||||
}
|
||||
|
||||
router.use(async (req: Request, _res: Response, next) => {
|
||||
try {
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store;
|
||||
requestContext.run(scopedStore, next);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Roadmap Endpoints ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/roadmaps
|
||||
* List all roadmaps.
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const roadmaps = roadmapStore.listRoadmaps();
|
||||
res.json(roadmaps);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to list roadmaps");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/roadmaps
|
||||
* Create a new roadmap.
|
||||
*/
|
||||
router.post("/", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { title, description } = req.body as { title: string; description?: string };
|
||||
|
||||
const validatedTitle = validateTitle(title);
|
||||
const validatedDesc = validateDescription(description);
|
||||
|
||||
const roadmap = roadmapStore.createRoadmap({
|
||||
title: validatedTitle,
|
||||
description: validatedDesc,
|
||||
});
|
||||
res.status(201).json(roadmap);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to create roadmap");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/roadmaps/:roadmapId
|
||||
* Get a roadmap with full hierarchy (milestones and features).
|
||||
*/
|
||||
router.get("/:roadmapId", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
|
||||
const roadmap = roadmapStore.getRoadmapWithHierarchy(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw notFound(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
res.json(roadmap);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to get roadmap");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/roadmaps/:roadmapId
|
||||
* Update roadmap metadata.
|
||||
*/
|
||||
router.patch("/:roadmapId", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
const { title, description } = req.body as { title?: string; description?: string };
|
||||
|
||||
const roadmap = roadmapStore.updateRoadmap(roadmapId, {
|
||||
title: title !== undefined ? validateTitle(title) : undefined,
|
||||
description: description !== undefined ? validateDescription(description) : undefined,
|
||||
});
|
||||
res.json(roadmap);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to update roadmap");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/roadmaps/:roadmapId
|
||||
* Delete a roadmap and all its milestones/features.
|
||||
*/
|
||||
router.delete("/:roadmapId", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
|
||||
roadmapStore.deleteRoadmap(roadmapId);
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to delete roadmap");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Milestone Endpoints ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/roadmaps/:roadmapId/milestones
|
||||
* Create a new milestone in a roadmap.
|
||||
*/
|
||||
router.post("/:roadmapId/milestones", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
const { title, description } = req.body as { title: string; description?: string };
|
||||
|
||||
const validatedTitle = validateTitle(title);
|
||||
const validatedDesc = validateDescription(description);
|
||||
|
||||
const milestone = roadmapStore.createMilestone(roadmapId, {
|
||||
title: validatedTitle,
|
||||
description: validatedDesc,
|
||||
});
|
||||
res.status(201).json(milestone);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to create milestone");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/roadmaps/:roadmapId/milestones/reorder
|
||||
* Reorder milestones within a roadmap.
|
||||
*/
|
||||
router.post("/:roadmapId/milestones/reorder", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
const { orderedMilestoneIds } = req.body as { orderedMilestoneIds: string[] };
|
||||
|
||||
validateStringArray(orderedMilestoneIds, "orderedMilestoneIds");
|
||||
|
||||
roadmapStore.reorderMilestones({ roadmapId, orderedMilestoneIds });
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to reorder milestones");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/roadmaps/milestones/:milestoneId
|
||||
* Update a milestone.
|
||||
*/
|
||||
router.patch("/milestones/:milestoneId", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { milestoneId } = req.params;
|
||||
const { title, description } = req.body as { title?: string; description?: string };
|
||||
|
||||
const milestone = roadmapStore.updateMilestone(milestoneId, {
|
||||
title: title !== undefined ? validateTitle(title) : undefined,
|
||||
description: description !== undefined ? validateDescription(description) : undefined,
|
||||
});
|
||||
res.json(milestone);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to update milestone");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/roadmaps/milestones/:milestoneId
|
||||
* Delete a milestone and all its features.
|
||||
*/
|
||||
router.delete("/milestones/:milestoneId", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
roadmapStore.deleteMilestone(milestoneId);
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to delete milestone");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Feature Endpoints ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/roadmaps/milestones/:milestoneId/features
|
||||
* Create a new feature in a milestone.
|
||||
*/
|
||||
router.post("/milestones/:milestoneId/features", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { milestoneId } = req.params;
|
||||
const { title, description } = req.body as { title: string; description?: string };
|
||||
|
||||
const validatedTitle = validateTitle(title);
|
||||
const validatedDesc = validateDescription(description);
|
||||
|
||||
const feature = roadmapStore.createFeature(milestoneId, {
|
||||
title: validatedTitle,
|
||||
description: validatedDesc,
|
||||
});
|
||||
res.status(201).json(feature);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to create feature");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/roadmaps/milestones/:milestoneId/features/reorder
|
||||
* Reorder features within a milestone.
|
||||
*/
|
||||
router.post("/milestones/:milestoneId/features/reorder", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { milestoneId } = req.params;
|
||||
const { orderedFeatureIds } = req.body as { orderedFeatureIds: string[] };
|
||||
|
||||
validateStringArray(orderedFeatureIds, "orderedFeatureIds");
|
||||
|
||||
// Get the milestone to find the roadmapId
|
||||
const milestone = roadmapStore.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw notFound(`Milestone ${milestoneId} not found`);
|
||||
}
|
||||
|
||||
roadmapStore.reorderFeatures({
|
||||
roadmapId: milestone.roadmapId,
|
||||
milestoneId,
|
||||
orderedFeatureIds,
|
||||
});
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to reorder features");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/roadmaps/features/:featureId
|
||||
* Update a feature.
|
||||
*/
|
||||
router.patch("/features/:featureId", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { featureId } = req.params;
|
||||
const { title, description } = req.body as { title?: string; description?: string };
|
||||
|
||||
const feature = roadmapStore.updateFeature(featureId, {
|
||||
title: title !== undefined ? validateTitle(title) : undefined,
|
||||
description: description !== undefined ? validateDescription(description) : undefined,
|
||||
});
|
||||
res.json(feature);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to update feature");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/roadmaps/features/:featureId
|
||||
* Delete a feature.
|
||||
*/
|
||||
router.delete("/features/:featureId", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { featureId } = req.params;
|
||||
|
||||
roadmapStore.deleteFeature(featureId);
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to delete feature");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/roadmaps/features/:featureId/move
|
||||
* Move a feature to a different milestone or position.
|
||||
*/
|
||||
router.post("/features/:featureId/move", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { featureId } = req.params;
|
||||
const { targetMilestoneId, targetIndex } = req.body as {
|
||||
targetMilestoneId: string;
|
||||
targetIndex: number;
|
||||
};
|
||||
|
||||
if (!targetMilestoneId) {
|
||||
throw badRequest("targetMilestoneId is required");
|
||||
}
|
||||
if (typeof targetIndex !== "number") {
|
||||
throw badRequest("targetIndex must be a number");
|
||||
}
|
||||
|
||||
// Get the feature and source milestone
|
||||
const feature = roadmapStore.getFeature(featureId);
|
||||
if (!feature) {
|
||||
throw notFound(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
|
||||
if (!fromMilestone) {
|
||||
throw notFound(`Source milestone ${feature.milestoneId} not found`);
|
||||
}
|
||||
|
||||
const toMilestone = roadmapStore.getMilestone(targetMilestoneId);
|
||||
if (!toMilestone) {
|
||||
throw notFound(`Target milestone ${targetMilestoneId} not found`);
|
||||
}
|
||||
|
||||
roadmapStore.moveFeature({
|
||||
roadmapId: fromMilestone.roadmapId,
|
||||
featureId,
|
||||
fromMilestoneId: feature.milestoneId,
|
||||
toMilestoneId: targetMilestoneId,
|
||||
targetOrderIndex: targetIndex,
|
||||
});
|
||||
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to move feature");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Suggestion Endpoints ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/roadmaps/:roadmapId/suggestions/milestones
|
||||
* Generate milestone suggestions using AI.
|
||||
*/
|
||||
router.post("/:roadmapId/suggestions/milestones", async (req, res) => {
|
||||
// Route-level timeout as safety net (slightly longer than internal timeout)
|
||||
const ROUTE_TIMEOUT_MS = SUGGESTION_TIMEOUT_MS + 10_000;
|
||||
const routeTimeoutId = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
res.status(503).json({ error: "Request timed out" });
|
||||
}
|
||||
}, ROUTE_TIMEOUT_MS);
|
||||
|
||||
// Clean up timeout on connection close
|
||||
res.on("close", () => {
|
||||
if (routeTimeoutId) clearTimeout(routeTimeoutId);
|
||||
});
|
||||
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const scopedStore = getScopedStore();
|
||||
const { roadmapId } = req.params;
|
||||
|
||||
// Check if roadmap exists
|
||||
const roadmap = roadmapStore.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw notFound(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
// Validate input
|
||||
let input: { goalPrompt: string; count?: number };
|
||||
try {
|
||||
validateSuggestionInput(req.body);
|
||||
input = req.body as { goalPrompt: string; count?: number };
|
||||
} catch (err) {
|
||||
if (err instanceof SuggestionValidationError) {
|
||||
throw badRequest(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Get project root directory for AI context
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
|
||||
// Generate suggestions
|
||||
try {
|
||||
const suggestions = await generateMilestoneSuggestions(
|
||||
input.goalPrompt,
|
||||
input.count,
|
||||
rootDir
|
||||
);
|
||||
|
||||
res.json({ suggestions });
|
||||
} catch (err) {
|
||||
if (err instanceof SuggestionParseError) {
|
||||
throw internalError(err.message);
|
||||
}
|
||||
if (err instanceof SuggestionServiceUnavailableError) {
|
||||
res.status(503).json({ error: err.message });
|
||||
for (const route of routes) {
|
||||
const handler = async (req: Request, res: Response) => {
|
||||
const result = await route.handler(req, await buildContext(store));
|
||||
if (isRouteResponse(result)) {
|
||||
if (result.status === 204) {
|
||||
res.status(204).send();
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to generate milestone suggestions");
|
||||
} finally {
|
||||
if (routeTimeoutId) clearTimeout(routeTimeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/roadmaps/milestones/:milestoneId/suggestions/features
|
||||
* Generate feature suggestions using AI.
|
||||
*/
|
||||
router.post("/milestones/:milestoneId/suggestions/features", async (req, res) => {
|
||||
// Route-level timeout as safety net (slightly longer than internal timeout)
|
||||
const ROUTE_TIMEOUT_MS = SUGGESTION_TIMEOUT_MS + 10_000;
|
||||
const routeTimeoutId = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
res.status(503).json({ error: "Request timed out" });
|
||||
}
|
||||
}, ROUTE_TIMEOUT_MS);
|
||||
|
||||
// Clean up timeout on connection close
|
||||
res.on("close", () => {
|
||||
if (routeTimeoutId) clearTimeout(routeTimeoutId);
|
||||
});
|
||||
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const scopedStore = getScopedStore();
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
// Get the milestone to find the roadmap
|
||||
const milestone = roadmapStore.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw notFound(`Milestone ${milestoneId} not found`);
|
||||
}
|
||||
|
||||
// Get the roadmap for context
|
||||
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
|
||||
if (!roadmap) {
|
||||
throw notFound(`Roadmap ${milestone.roadmapId} not found`);
|
||||
}
|
||||
|
||||
// Get existing features for this milestone
|
||||
const existingFeatures = roadmapStore.listFeatures(milestoneId);
|
||||
const existingFeatureTitles = existingFeatures.map((f) => f.title);
|
||||
|
||||
// Validate input
|
||||
let input: { prompt?: string; count?: number };
|
||||
try {
|
||||
validateFeatureSuggestionInput(req.body);
|
||||
input = req.body as { prompt?: string; count?: number };
|
||||
} catch (err) {
|
||||
if (err instanceof SuggestionValidationError) {
|
||||
throw badRequest(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Build the context for feature suggestion
|
||||
const context = {
|
||||
roadmapTitle: roadmap.title,
|
||||
roadmapDescription: roadmap.description,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneDescription: milestone.description,
|
||||
existingFeatureTitles,
|
||||
};
|
||||
|
||||
// Get project root directory for AI context
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
|
||||
// Generate suggestions
|
||||
try {
|
||||
const suggestions = await generateFeatureSuggestions(
|
||||
context,
|
||||
input.count,
|
||||
input.prompt,
|
||||
rootDir
|
||||
);
|
||||
|
||||
res.json({ suggestions });
|
||||
} catch (err) {
|
||||
if (err instanceof SuggestionParseError) {
|
||||
throw internalError(err.message);
|
||||
}
|
||||
if (err instanceof SuggestionServiceUnavailableError) {
|
||||
res.status(503).json({ error: err.message });
|
||||
if (result.body === undefined) {
|
||||
res.status(result.status).send();
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
res.status(result.status).json(result.body);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to generate feature suggestions");
|
||||
} finally {
|
||||
if (routeTimeoutId) clearTimeout(routeTimeoutId);
|
||||
}
|
||||
});
|
||||
res.status(200).json(result);
|
||||
};
|
||||
|
||||
// ── Export / Handoff Endpoints ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/roadmaps/:roadmapId/export
|
||||
* Get a flat export bundle for the roadmap.
|
||||
*/
|
||||
router.get("/:roadmapId/export", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
|
||||
const export_ = roadmapStore.getRoadmapExport(roadmapId);
|
||||
res.json(export_);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to export roadmap");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/roadmaps/:roadmapId/handoff
|
||||
* Get both mission-oriented and task-oriented handoff payloads for the roadmap.
|
||||
*
|
||||
* This is a convenience endpoint that combines both handoff types in a single response.
|
||||
* Returns 404 if the roadmap is not found.
|
||||
*/
|
||||
router.get("/:roadmapId/handoff", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
|
||||
// Get both handoff types
|
||||
const missionHandoff = roadmapStore.getMissionPlanningHandoff(roadmapId);
|
||||
const featureHandoffs = roadmapStore.listFeatureTaskPlanningHandoffs(roadmapId);
|
||||
|
||||
res.json({
|
||||
mission: missionHandoff,
|
||||
features: featureHandoffs,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
// Handle not-found case from store methods
|
||||
if (err instanceof Error && err.message.includes("not found")) {
|
||||
throw notFound(err.message);
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to generate handoff");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/roadmaps/:roadmapId/handoff/mission
|
||||
* Get a mission planning handoff payload for the roadmap.
|
||||
*/
|
||||
router.get("/:roadmapId/handoff/mission", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
|
||||
const handoff = roadmapStore.getMissionPlanningHandoff(roadmapId);
|
||||
res.json(handoff);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
// Handle not-found case from store methods
|
||||
if (err instanceof Error && err.message.includes("not found")) {
|
||||
throw notFound(err.message);
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to generate mission handoff");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task
|
||||
* Get a task planning handoff payload for a single feature.
|
||||
*/
|
||||
router.get("/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId, milestoneId, featureId } = req.params;
|
||||
|
||||
const handoff = roadmapStore.getRoadmapFeatureHandoff(roadmapId, milestoneId, featureId);
|
||||
res.json(handoff);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to generate task handoff");
|
||||
}
|
||||
});
|
||||
registerRoute(router, route, handler, normalizeLegacyRoadmapPath(route.path));
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function normalizeLegacyRoadmapPath(path: string): string {
|
||||
if (path === "/roadmaps") return "/";
|
||||
if (path.startsWith("/roadmaps/")) return path.slice("/roadmaps".length);
|
||||
return path;
|
||||
}
|
||||
|
||||
function registerRoute(
|
||||
router: Router,
|
||||
route: PluginRouteDefinition,
|
||||
handler: (req: Request, res: Response) => Promise<void>,
|
||||
normalizedPath: string,
|
||||
): void {
|
||||
switch (route.method) {
|
||||
case "GET":
|
||||
router.get(normalizedPath, handler);
|
||||
break;
|
||||
case "POST":
|
||||
router.post(normalizedPath, handler);
|
||||
break;
|
||||
case "PUT":
|
||||
router.put(normalizedPath, handler);
|
||||
break;
|
||||
case "PATCH":
|
||||
router.patch(normalizedPath, handler);
|
||||
break;
|
||||
case "DELETE":
|
||||
router.delete(normalizedPath, handler);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export { createRoadmapPluginRoutes };
|
||||
|
||||
@@ -1,879 +1,15 @@
|
||||
/**
|
||||
* Roadmap Milestone Suggestion Generation Service
|
||||
*
|
||||
* Provides AI-powered milestone suggestion generation for roadmaps.
|
||||
* Users can generate milestone ideas from a goal prompt and accept them
|
||||
* into their roadmap.
|
||||
*
|
||||
* Features:
|
||||
* - AI agent integration via dynamic import of @fusion/engine
|
||||
* - Planning-style JSON extraction with repair
|
||||
* - Input validation (goal prompt max length, count bounds)
|
||||
* - Read-only endpoint (no persistence of suggestions)
|
||||
* - Error mapping (validation 400, not found 404, AI/parser 500/503)
|
||||
*/
|
||||
|
||||
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let createFnAgent: any = engineCreateFnAgent;
|
||||
|
||||
async function initEngine(): Promise<void> {
|
||||
// Engine is statically imported; nothing to do.
|
||||
}
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Input for generating milestone suggestions */
|
||||
export interface GenerateMilestoneSuggestionsInput {
|
||||
/** The goal prompt/description for the roadmap */
|
||||
goalPrompt: string;
|
||||
/** Number of milestones to generate (default 5, max 10) */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** A suggested milestone with title and optional description */
|
||||
export interface MilestoneSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** System prompt for milestone suggestion generation */
|
||||
export const MILESTONE_SUGGESTION_SYSTEM_PROMPT = `You are a milestone planning assistant for a product roadmap system.
|
||||
|
||||
Your job is to suggest logical milestones that would help achieve a user's roadmap goal.
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. **Think about phases**: Break the goal into logical phases (e.g., "Foundation", "Core Features", "Polish", "Launch")
|
||||
2. **Use clear titles**: Milestone titles should be concise and descriptive (e.g., "Authentication System", "User Dashboard MVP")
|
||||
3. **Add context**: Include a brief description explaining what this milestone encompasses
|
||||
4. **Order matters**: List milestones in the order they should be completed
|
||||
5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond with ONLY a valid JSON array of milestone suggestions:
|
||||
|
||||
[
|
||||
{
|
||||
"title": "Milestone Title",
|
||||
"description": "Brief description of what this milestone covers (1-2 sentences)"
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
Do NOT include any markdown formatting, code fences, or additional text. Only output the JSON array.`;
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Maximum length for goal prompt */
|
||||
const MAX_GOAL_PROMPT_LENGTH = 4000;
|
||||
|
||||
/** Timeout for AI suggestion generation (2 minutes) */
|
||||
export const SUGGESTION_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** Default number of suggestions to generate */
|
||||
const DEFAULT_SUGGESTION_COUNT = 5;
|
||||
|
||||
/** Maximum number of suggestions to generate */
|
||||
const MAX_SUGGESTION_COUNT = 10;
|
||||
|
||||
/** Minimum number of suggestions to generate */
|
||||
const MIN_SUGGESTION_COUNT = 1;
|
||||
|
||||
/** Max number of retry attempts when AI returns unparseable output */
|
||||
const MAX_PARSE_RETRIES = 1;
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate the input for generating milestone suggestions.
|
||||
* Throws with a descriptive error message on validation failure.
|
||||
*/
|
||||
export function validateSuggestionInput(input: unknown): asserts input is GenerateMilestoneSuggestionsInput {
|
||||
if (!input || typeof input !== "object") {
|
||||
throw new ValidationError("Request body must be an object");
|
||||
}
|
||||
|
||||
const { goalPrompt, count } = input as Record<string, unknown>;
|
||||
|
||||
// Validate goalPrompt
|
||||
if (typeof goalPrompt !== "string" || !goalPrompt.trim()) {
|
||||
throw new ValidationError("goalPrompt is required and must be a non-empty string");
|
||||
}
|
||||
|
||||
if (goalPrompt.length > MAX_GOAL_PROMPT_LENGTH) {
|
||||
throw new ValidationError(
|
||||
`goalPrompt exceeds maximum length of ${MAX_GOAL_PROMPT_LENGTH} characters`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate count (optional)
|
||||
if (count !== undefined) {
|
||||
if (typeof count !== "number" || !Number.isInteger(count)) {
|
||||
throw new ValidationError("count must be an integer");
|
||||
}
|
||||
|
||||
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||
throw new ValidationError(
|
||||
`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON Extraction ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the best JSON candidate from AI response text.
|
||||
* Handles markdown-wrapped JSON, embedded JSON, and balanced brace extraction.
|
||||
*/
|
||||
function extractJsonCandidate(text: string): string | null {
|
||||
if (!text || !text.trim()) return null;
|
||||
|
||||
// 1. Try markdown code blocks first (most reliable)
|
||||
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||||
if (codeBlockMatch?.[1]) {
|
||||
const candidate = codeBlockMatch[1].trim();
|
||||
if (candidate.startsWith("[")) return candidate;
|
||||
}
|
||||
|
||||
// 2. Find all top-level bracket-delimited arrays using balanced counting
|
||||
const candidates: Array<{ start: number; end: number; text: string }> = [];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === "[") {
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (let j = i; j < text.length; j++) {
|
||||
const ch = text[j];
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
if (ch === "[") depth++;
|
||||
if (ch === "]") depth--;
|
||||
if (depth === 0) {
|
||||
const candidate = text.slice(i, j + 1).trim();
|
||||
// Only accept candidates that parse as valid JSON
|
||||
try {
|
||||
JSON.parse(candidate);
|
||||
candidates.push({ start: i, end: j, text: candidate });
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the largest valid candidate (most likely the full response)
|
||||
if (candidates.length > 0) {
|
||||
candidates.sort((a, b) => b.text.length - a.text.length);
|
||||
return candidates[0].text;
|
||||
}
|
||||
|
||||
// 3. Last resort: try the full trimmed text
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("[")) return trimmed;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to repair common JSON issues:
|
||||
* - Truncated JSON (missing closing brackets/braces)
|
||||
* - Trailing commas before closing brackets/braces
|
||||
* - Missing closing quotes
|
||||
*/
|
||||
function repairJson(text: string): string {
|
||||
let repaired = text;
|
||||
|
||||
// Fix trailing commas before } or ]
|
||||
repaired = repaired.replace(/,\s*([}\]])/g, "$1");
|
||||
|
||||
// Count open/close braces and brackets
|
||||
let openBraces = 0;
|
||||
let openBrackets = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (const ch of repaired) {
|
||||
if (escape) { escape = false; continue; }
|
||||
if (ch === "\\") { escape = true; continue; }
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (ch === "{") openBraces++;
|
||||
if (ch === "}") openBraces--;
|
||||
if (ch === "[") openBrackets++;
|
||||
if (ch === "]") openBrackets--;
|
||||
}
|
||||
|
||||
// If we're in an unclosed string, close it
|
||||
if (inString) {
|
||||
repaired += '"';
|
||||
}
|
||||
|
||||
// Re-count after potential string fix
|
||||
openBraces = 0;
|
||||
openBrackets = 0;
|
||||
inString = false;
|
||||
escape = false;
|
||||
for (const ch of repaired) {
|
||||
if (escape) { escape = false; continue; }
|
||||
if (ch === "\\") { escape = true; continue; }
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (ch === "{") openBraces++;
|
||||
if (ch === "}") openBraces--;
|
||||
if (ch === "[") openBrackets++;
|
||||
if (ch === "]") openBrackets--;
|
||||
}
|
||||
|
||||
// Close unclosed brackets and braces
|
||||
repaired += "]".repeat(Math.max(0, openBrackets));
|
||||
repaired += "}".repeat(Math.max(0, openBraces));
|
||||
|
||||
return repaired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AI response JSON with robust extraction and recovery.
|
||||
*/
|
||||
function parseMilestoneSuggestions(text: string): MilestoneSuggestion[] {
|
||||
const candidate = extractJsonCandidate(text);
|
||||
|
||||
if (!candidate) {
|
||||
throw new ParseError("AI returned no valid JSON. Please try again.");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
} catch {
|
||||
// Attempt repair for truncated/malformed JSON
|
||||
try {
|
||||
const repaired = repairJson(candidate);
|
||||
parsed = JSON.parse(repaired);
|
||||
} catch (repairErr) {
|
||||
throw new ParseError(
|
||||
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate structure: must be an array
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new ParseError("AI response must be a JSON array of milestone suggestions");
|
||||
}
|
||||
|
||||
// Validate and normalize each item - filter invalid entries per spec
|
||||
const suggestions: MilestoneSuggestion[] = [];
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const item = parsed[i];
|
||||
|
||||
// Skip items that are not objects
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { title, description } = item as Record<string, unknown>;
|
||||
|
||||
// Skip entries with empty/whitespace-only titles per spec
|
||||
if (typeof title !== "string" || !title.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
title: title.trim(),
|
||||
description: typeof description === "string" && description.trim()
|
||||
? description.trim()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// If zero valid rows remain after filtering, return 500 error per spec
|
||||
if (suggestions.length === 0) {
|
||||
throw new ParseError("AI returned no valid milestone suggestions");
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
// ── Generation ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate milestone suggestions from a goal prompt.
|
||||
*
|
||||
* @param goalPrompt - The goal/description for the roadmap
|
||||
* @param count - Number of suggestions to generate (default 5, max 10)
|
||||
* @param rootDir - Project root directory for AI context
|
||||
* @param modelProvider - Optional AI model provider override
|
||||
* @param modelId - Optional AI model ID override
|
||||
* @returns Array of milestone suggestions
|
||||
*/
|
||||
export async function generateMilestoneSuggestions(
|
||||
goalPrompt: string,
|
||||
count: number = DEFAULT_SUGGESTION_COUNT,
|
||||
rootDir?: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
): Promise<MilestoneSuggestion[]> {
|
||||
// Ensure engine is loaded before using createFnAgent
|
||||
await initEngine();
|
||||
|
||||
if (!createFnAgent) {
|
||||
throw new ServiceUnavailableError("AI service is not available");
|
||||
}
|
||||
|
||||
if (!rootDir) {
|
||||
throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||
}
|
||||
|
||||
// Race AI generation against a timeout to prevent hanging requests
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
let agent: ReturnType<typeof createFnAgent> | undefined;
|
||||
|
||||
try {
|
||||
// Create AI agent with milestone suggestion system prompt
|
||||
agent = await createFnAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId
|
||||
? {
|
||||
defaultProvider: modelProvider,
|
||||
defaultModelId: modelId,
|
||||
}
|
||||
: {}),
|
||||
onThinking: () => {
|
||||
// Ignore thinking output for milestone suggestions
|
||||
},
|
||||
onText: () => {
|
||||
// Ignore incremental text
|
||||
},
|
||||
});
|
||||
|
||||
// Send the goal prompt with count instruction
|
||||
const userMessage = `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`;
|
||||
|
||||
// Get response from AI
|
||||
await agent.session.prompt(userMessage);
|
||||
|
||||
// Extract response text from agent state
|
||||
interface AgentMessage {
|
||||
role: string;
|
||||
content?: string | Array<{ type: string; text: string }>;
|
||||
}
|
||||
const lastMessage = (agent.session.state.messages as AgentMessage[])
|
||||
.filter((m: AgentMessage) => m.role === "assistant")
|
||||
.pop();
|
||||
|
||||
let responseText = "";
|
||||
if (lastMessage?.content) {
|
||||
if (typeof lastMessage.content === "string") {
|
||||
responseText = lastMessage.content;
|
||||
} else if (Array.isArray(lastMessage.content)) {
|
||||
responseText = lastMessage.content
|
||||
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c: { type: string; text: string }) => c.text)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the JSON response with retry
|
||||
let suggestions: MilestoneSuggestion[] | undefined;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||
try {
|
||||
suggestions = parseMilestoneSuggestions(responseText);
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
if (attempt < MAX_PARSE_RETRIES) {
|
||||
// Retry: ask the AI to reformat as clean JSON
|
||||
try {
|
||||
await agent.session.prompt(
|
||||
"Your previous response could not be parsed as JSON. " +
|
||||
"Please respond with ONLY a JSON array of milestone suggestions in this format: " +
|
||||
'[{"title": "Milestone Title", "description": "Brief description"}, ...]. ' +
|
||||
"No markdown, no explanation, just the JSON array."
|
||||
);
|
||||
|
||||
// Get the new response text
|
||||
const retryMessage = (agent.session.state.messages as AgentMessage[])
|
||||
.filter((m: AgentMessage) => m.role === "assistant")
|
||||
.pop();
|
||||
|
||||
let retryText = "";
|
||||
if (retryMessage?.content) {
|
||||
if (typeof retryMessage.content === "string") {
|
||||
retryText = retryMessage.content;
|
||||
} else if (Array.isArray(retryMessage.content)) {
|
||||
retryText = retryMessage.content
|
||||
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c: { type: string; text: string }) => c.text)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
responseText = retryText;
|
||||
} catch {
|
||||
// Retry prompt itself failed — give up
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!suggestions) {
|
||||
throw new ParseError(
|
||||
`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
|
||||
// Limit to requested count
|
||||
return suggestions.slice(0, count);
|
||||
} finally {
|
||||
// Always dispose the agent session (inside the raced promise so cleanup happens when this settles)
|
||||
if (agent) {
|
||||
try {
|
||||
agent.session.dispose?.();
|
||||
} catch {
|
||||
// Ignore disposal errors
|
||||
}
|
||||
}
|
||||
}
|
||||
})(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")),
|
||||
SUGGESTION_TIMEOUT_MS
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// FEATURE SUGGESTION GENERATION
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Input for generating feature suggestions within a milestone */
|
||||
export interface GenerateFeatureSuggestionsInput {
|
||||
/** Optional prompt to guide feature generation */
|
||||
prompt?: string;
|
||||
/** Number of features to generate (default 5, max 10) */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** A suggested feature with title and optional description */
|
||||
export interface FeatureSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Context about the milestone for feature generation */
|
||||
export interface FeatureSuggestionContext {
|
||||
/** Roadmap title */
|
||||
roadmapTitle: string;
|
||||
/** Roadmap description (optional) */
|
||||
roadmapDescription?: string;
|
||||
/** Milestone title */
|
||||
milestoneTitle: string;
|
||||
/** Milestone description (optional) */
|
||||
milestoneDescription?: string;
|
||||
/** Existing feature titles in this milestone */
|
||||
existingFeatureTitles: string[];
|
||||
}
|
||||
|
||||
/** System prompt for feature suggestion generation */
|
||||
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.
|
||||
|
||||
Your job is to suggest concrete, actionable features that belong within a specific milestone.
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. **Be specific**: Feature titles should clearly describe what will be built (e.g., "User profile avatar upload", "API rate limiting")
|
||||
2. **Actionable scope**: Each feature should be achievable in 1-2 weeks of focused work
|
||||
3. **Add context**: Include a brief description explaining the feature's purpose and key aspects
|
||||
4. **Avoid duplication**: Do NOT suggest features that are similar to existing ones already planned
|
||||
5. **Order matters**: List features in the order they should be implemented within this milestone
|
||||
|
||||
## Context
|
||||
|
||||
The features should fit within the following milestone:
|
||||
{MILESTONE_CONTEXT}
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond with ONLY a valid JSON array of feature suggestions:
|
||||
|
||||
[
|
||||
{
|
||||
"title": "Feature Title",
|
||||
"description": "Brief description of the feature (1-2 sentences)"
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
Do NOT include any markdown formatting, code fences, or additional text. Only output the JSON array.`;
|
||||
|
||||
/** Maximum length for feature generation prompt */
|
||||
const MAX_FEATURE_PROMPT_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* Validate the input for generating feature suggestions.
|
||||
* Throws with a descriptive error message on validation failure.
|
||||
*/
|
||||
export function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput {
|
||||
if (!input || typeof input !== "object") {
|
||||
throw new ValidationError("Request body must be an object");
|
||||
}
|
||||
|
||||
// Arrays are objects in JS, but not valid input
|
||||
if (Array.isArray(input)) {
|
||||
throw new ValidationError("Request body must be an object, not an array");
|
||||
}
|
||||
|
||||
const { prompt, count } = input as Record<string, unknown>;
|
||||
|
||||
// Validate prompt (optional)
|
||||
if (prompt !== undefined) {
|
||||
if (typeof prompt !== "string") {
|
||||
throw new ValidationError("prompt must be a string");
|
||||
}
|
||||
|
||||
if (prompt.length > MAX_FEATURE_PROMPT_LENGTH) {
|
||||
throw new ValidationError(
|
||||
`prompt exceeds maximum length of ${MAX_FEATURE_PROMPT_LENGTH} characters`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate count (optional)
|
||||
if (count !== undefined) {
|
||||
if (typeof count !== "number" || !Number.isInteger(count)) {
|
||||
throw new ValidationError("count must be an integer");
|
||||
}
|
||||
|
||||
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||
throw new ValidationError(
|
||||
`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the milestone context string for the system prompt.
|
||||
*/
|
||||
function buildMilestoneContextString(context: FeatureSuggestionContext): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`Roadmap: ${context.roadmapTitle}`);
|
||||
if (context.roadmapDescription) {
|
||||
lines.push(`Description: ${context.roadmapDescription}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(`Milestone: ${context.milestoneTitle}`);
|
||||
if (context.milestoneDescription) {
|
||||
lines.push(`Description: ${context.milestoneDescription}`);
|
||||
}
|
||||
|
||||
if (context.existingFeatureTitles.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Existing features in this milestone:");
|
||||
for (const title of context.existingFeatureTitles) {
|
||||
lines.push(` - ${title}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AI response for feature suggestions with robust extraction and recovery.
|
||||
*/
|
||||
function parseFeatureSuggestions(text: string): FeatureSuggestion[] {
|
||||
const candidate = extractJsonCandidate(text);
|
||||
|
||||
if (!candidate) {
|
||||
throw new ParseError("AI returned no valid JSON. Please try again.");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
} catch {
|
||||
// Attempt repair for truncated/malformed JSON
|
||||
try {
|
||||
const repaired = repairJson(candidate);
|
||||
parsed = JSON.parse(repaired);
|
||||
} catch (repairErr) {
|
||||
throw new ParseError(
|
||||
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate structure: must be an array
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new ParseError("AI response must be a JSON array of feature suggestions");
|
||||
}
|
||||
|
||||
// Validate and normalize each item - filter invalid entries per spec
|
||||
const suggestions: FeatureSuggestion[] = [];
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const item = parsed[i];
|
||||
|
||||
// Skip items that are not objects
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { title, description } = item as Record<string, unknown>;
|
||||
|
||||
// Skip entries with empty/whitespace-only titles per spec
|
||||
if (typeof title !== "string" || !title.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
title: title.trim(),
|
||||
description: typeof description === "string" && description.trim()
|
||||
? description.trim()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// If zero valid rows remain after filtering, return 500 error per spec
|
||||
if (suggestions.length === 0) {
|
||||
throw new ParseError("AI returned no valid feature suggestions");
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate feature suggestions for a specific milestone.
|
||||
*
|
||||
* @param context - Context about the milestone (roadmap info, milestone info, existing features)
|
||||
* @param count - Number of suggestions to generate (default 5, max 10)
|
||||
* @param prompt - Optional additional prompt to guide generation
|
||||
* @param rootDir - Project root directory for AI context
|
||||
* @param modelProvider - Optional AI model provider override
|
||||
* @param modelId - Optional AI model ID override
|
||||
* @returns Array of feature suggestions
|
||||
*/
|
||||
export async function generateFeatureSuggestions(
|
||||
context: FeatureSuggestionContext,
|
||||
count: number = DEFAULT_SUGGESTION_COUNT,
|
||||
prompt?: string,
|
||||
rootDir?: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
): Promise<FeatureSuggestion[]> {
|
||||
// Ensure engine is loaded before using createFnAgent
|
||||
await initEngine();
|
||||
|
||||
if (!createFnAgent) {
|
||||
throw new ServiceUnavailableError("AI service is not available");
|
||||
}
|
||||
|
||||
if (!rootDir) {
|
||||
throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||
}
|
||||
|
||||
// Build the milestone context string
|
||||
const milestoneContextStr = buildMilestoneContextString(context);
|
||||
|
||||
// Build the system prompt with dynamic context
|
||||
const systemPrompt = FEATURE_SUGGESTION_SYSTEM_PROMPT.replace(
|
||||
"{MILESTONE_CONTEXT}",
|
||||
milestoneContextStr
|
||||
);
|
||||
|
||||
// Race AI generation against a timeout to prevent hanging requests
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
let agent: ReturnType<typeof createFnAgent> | undefined;
|
||||
|
||||
try {
|
||||
// Create AI agent with feature suggestion system prompt
|
||||
agent = await createFnAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId
|
||||
? {
|
||||
defaultProvider: modelProvider,
|
||||
defaultModelId: modelId,
|
||||
}
|
||||
: {}),
|
||||
onThinking: () => {
|
||||
// Ignore thinking output for feature suggestions
|
||||
},
|
||||
onText: () => {
|
||||
// Ignore incremental text
|
||||
},
|
||||
});
|
||||
|
||||
// Build the user message
|
||||
let userMessage = `Please suggest ${count} features for the milestone described above.`;
|
||||
if (prompt && prompt.trim()) {
|
||||
userMessage += `\n\nAdditional guidance:\n${prompt.trim()}`;
|
||||
}
|
||||
|
||||
// Get response from AI
|
||||
await agent.session.prompt(userMessage);
|
||||
|
||||
// Extract response text from agent state
|
||||
interface AgentMessage {
|
||||
role: string;
|
||||
content?: string | Array<{ type: string; text: string }>;
|
||||
}
|
||||
const lastMessage = (agent.session.state.messages as AgentMessage[])
|
||||
.filter((m: AgentMessage) => m.role === "assistant")
|
||||
.pop();
|
||||
|
||||
let responseText = "";
|
||||
if (lastMessage?.content) {
|
||||
if (typeof lastMessage.content === "string") {
|
||||
responseText = lastMessage.content;
|
||||
} else if (Array.isArray(lastMessage.content)) {
|
||||
responseText = lastMessage.content
|
||||
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c: { type: string; text: string }) => c.text)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the JSON response with retry
|
||||
let suggestions: FeatureSuggestion[] | undefined;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||
try {
|
||||
suggestions = parseFeatureSuggestions(responseText);
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
if (attempt < MAX_PARSE_RETRIES) {
|
||||
// Retry: ask the AI to reformat as clean JSON
|
||||
try {
|
||||
await agent.session.prompt(
|
||||
"Your previous response could not be parsed as JSON. " +
|
||||
"Please respond with ONLY a JSON array of feature suggestions in this format: " +
|
||||
'[{"title": "Feature Title", "description": "Brief description"}, ...]. ' +
|
||||
"No markdown, no explanation, just the JSON array."
|
||||
);
|
||||
|
||||
// Get the new response text
|
||||
const retryMessage = (agent.session.state.messages as AgentMessage[])
|
||||
.filter((m: AgentMessage) => m.role === "assistant")
|
||||
.pop();
|
||||
|
||||
let retryText = "";
|
||||
if (retryMessage?.content) {
|
||||
if (typeof retryMessage.content === "string") {
|
||||
retryText = retryMessage.content;
|
||||
} else if (Array.isArray(retryMessage.content)) {
|
||||
retryText = retryMessage.content
|
||||
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c: { type: string; text: string }) => c.text)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
responseText = retryText;
|
||||
} catch {
|
||||
// Retry prompt itself failed — give up
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!suggestions) {
|
||||
throw new ParseError(
|
||||
`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
|
||||
// Limit to requested count
|
||||
return suggestions.slice(0, count);
|
||||
} finally {
|
||||
// Always dispose the agent session (inside the raced promise so cleanup happens when this settles)
|
||||
if (agent) {
|
||||
try {
|
||||
agent.session.dispose?.();
|
||||
} catch {
|
||||
// Ignore disposal errors
|
||||
}
|
||||
}
|
||||
}
|
||||
})(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")),
|
||||
SUGGESTION_TIMEOUT_MS
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Custom Errors ───────────────────────────────────────────────────────────
|
||||
|
||||
export class ValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ParseError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ServiceUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reset module state. Used for testing only.
|
||||
*/
|
||||
export function __resetSuggestionState(): void {
|
||||
createFnAgent = engineCreateFnAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a mock createFnAgent function. Used for testing only.
|
||||
*/
|
||||
export function __setCreateFnAgent(mock: typeof createFnAgent): void {
|
||||
createFnAgent = mock;
|
||||
}
|
||||
export {
|
||||
FEATURE_SUGGESTION_SYSTEM_PROMPT,
|
||||
MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
ParseError,
|
||||
ServiceUnavailableError,
|
||||
SUGGESTION_TIMEOUT_MS,
|
||||
ValidationError,
|
||||
__resetSuggestionState,
|
||||
__setCreateAiSessionFactory,
|
||||
__setCreateFnAgent,
|
||||
generateFeatureSuggestions,
|
||||
generateMilestoneSuggestions,
|
||||
validateFeatureSuggestionInput,
|
||||
validateSuggestionInput,
|
||||
} from "../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user