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("agentRatings");
|
||||||
expect(tableNames).toContain("task_documents");
|
expect(tableNames).toContain("task_documents");
|
||||||
expect(tableNames).toContain("task_document_revisions");
|
expect(tableNames).toContain("task_document_revisions");
|
||||||
// Roadmap tables
|
// Roadmap tables are plugin-owned (FN-3159) and initialized via plugin schema hooks.
|
||||||
expect(tableNames).toContain("roadmaps");
|
|
||||||
expect(tableNames).toContain("roadmap_milestones");
|
|
||||||
expect(tableNames).toContain("roadmap_features");
|
|
||||||
// Verification cache (migration 61)
|
// Verification cache (migration 61)
|
||||||
expect(tableNames).toContain("verification_cache");
|
expect(tableNames).toContain("verification_cache");
|
||||||
expect(tableNames).toContain("distributed_task_id_state");
|
expect(tableNames).toContain("distributed_task_id_state");
|
||||||
@@ -156,9 +153,7 @@ describe("Database", () => {
|
|||||||
expect(indexNames).toContain("idxAgentApiKeysAgentId");
|
expect(indexNames).toContain("idxAgentApiKeysAgentId");
|
||||||
expect(indexNames).toContain("idxAgentConfigRevisionsAgentIdCreatedAt");
|
expect(indexNames).toContain("idxAgentConfigRevisionsAgentIdCreatedAt");
|
||||||
expect(indexNames).toContain("idxTasksCreatedAt");
|
expect(indexNames).toContain("idxTasksCreatedAt");
|
||||||
// Roadmap indexes
|
// Roadmap indexes are plugin-owned (FN-3159) and initialized via plugin schema hooks.
|
||||||
expect(indexNames).toContain("idxRoadmapMilestonesRoadmapOrder");
|
|
||||||
expect(indexNames).toContain("idxRoadmapFeaturesMilestoneOrder");
|
|
||||||
// Verification cache index (migration 61)
|
// Verification cache index (migration 61)
|
||||||
expect(indexNames).toContain("idxVerificationCacheRecordedAt");
|
expect(indexNames).toContain("idxVerificationCacheRecordedAt");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -685,51 +685,6 @@ CREATE TABLE IF NOT EXISTS routines (
|
|||||||
updatedAt TEXT NOT NULL
|
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)
|
-- Insight persistence tables (FN-1877)
|
||||||
-- Normalized insight entities and insight-generation run records
|
-- 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)
|
// Insight persistence tables (FN-1877)
|
||||||
// Normalized insight entities and insight-generation run records
|
// Normalized insight entities and insight-generation run records
|
||||||
if (version < 33) {
|
if (version < 33) {
|
||||||
|
|||||||
@@ -310,37 +310,6 @@ export {
|
|||||||
} from "./memory-compaction.js";
|
} from "./memory-compaction.js";
|
||||||
// Note: AiServiceError is shared with ai-summarize.ts and re-exported from there
|
// 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 {
|
export {
|
||||||
isTaskPriority,
|
isTaskPriority,
|
||||||
normalizeTaskPriority,
|
normalizeTaskPriority,
|
||||||
@@ -352,12 +321,6 @@ export {
|
|||||||
sortTasksForDisplayColumn,
|
sortTasksForDisplayColumn,
|
||||||
} from "./task-priority.js";
|
} from "./task-priority.js";
|
||||||
export type { TaskPrioritySortable, TaskColumnSortable } from "./task-priority.js";
|
export type { TaskPrioritySortable, TaskColumnSortable } from "./task-priority.js";
|
||||||
export {
|
|
||||||
mapFeatureToTaskHandoff,
|
|
||||||
mapRoadmapToMissionHandoff,
|
|
||||||
mapRoadmapWithHierarchyToMissionHandoff,
|
|
||||||
mapAllFeaturesToTaskHandoffs,
|
|
||||||
} from "./roadmap-handoff.js";
|
|
||||||
|
|
||||||
// ── Mission Hierarchy Types ────────────────────────────────────────────
|
// ── Mission Hierarchy Types ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -433,8 +396,6 @@ export type {
|
|||||||
} from "./mission-types.js";
|
} from "./mission-types.js";
|
||||||
export { MissionStore } from "./mission-store.js";
|
export { MissionStore } from "./mission-store.js";
|
||||||
export type { MissionStoreEvents, MissionSummary } 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) ───────────────────────────
|
// ── Central Infrastructure (Multi-Project Support) ───────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export interface PluginToolResult {
|
|||||||
|
|
||||||
// ── Plugin Routes ────────────────────────────────────────────────────
|
// ── Plugin Routes ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type PluginRouteMethod = "GET" | "POST" | "PUT" | "DELETE";
|
export type PluginRouteMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom dashboard API route definition.
|
* 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 { detectLegacyData, migrateFromLegacy } from "./db-migrate.js";
|
||||||
import { MissionStore } from "./mission-store.js";
|
import { MissionStore } from "./mission-store.js";
|
||||||
import { PluginStore } from "./plugin-store.js";
|
import { PluginStore } from "./plugin-store.js";
|
||||||
import { RoadmapStore } from "./roadmap-store.js";
|
|
||||||
import { InsightStore } from "./insight-store.js";
|
import { InsightStore } from "./insight-store.js";
|
||||||
import { ResearchStore } from "./research-store.js";
|
import { ResearchStore } from "./research-store.js";
|
||||||
import { TodoStore } from "./todo-store.js";
|
import { TodoStore } from "./todo-store.js";
|
||||||
@@ -513,8 +512,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
private missionStore: MissionStore | null = null;
|
private missionStore: MissionStore | null = null;
|
||||||
/** Cached PluginStore instance */
|
/** Cached PluginStore instance */
|
||||||
private pluginStore: PluginStore | null = null;
|
private pluginStore: PluginStore | null = null;
|
||||||
/** Cached RoadmapStore instance */
|
|
||||||
private roadmapStore: RoadmapStore | null = null;
|
|
||||||
/** Cached InsightStore instance */
|
/** Cached InsightStore instance */
|
||||||
private insightStore: InsightStore | null = null;
|
private insightStore: InsightStore | null = null;
|
||||||
/** Cached ResearchStore instance */
|
/** Cached ResearchStore instance */
|
||||||
@@ -6813,17 +6810,6 @@ ${notificationsSection}`;
|
|||||||
return this.pluginStore;
|
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.
|
* Get the InsightStore instance for project insights operations.
|
||||||
* Lazily initializes the InsightStore on first access.
|
* Lazily initializes the InsightStore on first access.
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"@codemirror/theme-one-dark": "^6.1.2",
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
"@codemirror/view": "^6.36.4",
|
"@codemirror/view": "^6.36.4",
|
||||||
"@fusion-plugin-examples/dependency-graph": "workspace:*",
|
"@fusion-plugin-examples/dependency-graph": "workspace:*",
|
||||||
|
"@fusion-plugin-examples/roadmap": "workspace:*",
|
||||||
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
|
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
|
||||||
"@fusion-plugin-examples/openclaw-runtime": "workspace:*",
|
"@fusion-plugin-examples/openclaw-runtime": "workspace:*",
|
||||||
"@fusion-plugin-examples/droid-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 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
|
// 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 MockValidationError extends Error { name = "ValidationError"; constructor(m: string) { super(m); } }
|
||||||
class MockParseError extends Error { name = "ParseError"; 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", () => {
|
describe("projectId scoping", () => {
|
||||||
it("uses projectId from query param", async () => {
|
it("ignores projectId query param in legacy adapter", async () => {
|
||||||
mockRoadmapStore.createRoadmap({ title: "Project Roadmap" });
|
mockRoadmapStore.createRoadmap({ title: "Project Roadmap" });
|
||||||
const response = await performGet(app, "/api/roadmaps?projectId=test-project");
|
const response = await performGet(app, "/api/roadmaps?projectId=test-project");
|
||||||
expect(response.status).toBe(200);
|
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", () => {
|
describe("POST /api/roadmaps/:roadmapId/suggestions/milestones", () => {
|
||||||
it("returns 503 when generation times out", async () => {
|
it("returns 503 when AI is unavailable", 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);
|
|
||||||
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
|
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
|
||||||
|
|
||||||
const response = await performRequest(
|
const response = await performRequest(
|
||||||
@@ -597,21 +588,12 @@ describe("Roadmap Routes", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(503);
|
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", () => {
|
describe("POST /api/roadmaps/milestones/:milestoneId/suggestions/features", () => {
|
||||||
it("returns 503 when generation times out", async () => {
|
it("returns 503 when AI is unavailable", 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);
|
|
||||||
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
|
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
|
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
|
||||||
|
|
||||||
@@ -624,7 +606,7 @@ describe("Roadmap Routes", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(503);
|
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 { Router, type Request, type Response } from "express";
|
||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
import { getCreateAiSessionFactory, type PluginContext, type PluginRouteDefinition, type TaskStore } from "@fusion/core";
|
||||||
import { TaskStore } from "@fusion/core";
|
import { createRoadmapPluginRoutes } from "../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js";
|
||||||
import {
|
|
||||||
ApiError,
|
|
||||||
badRequest,
|
|
||||||
notFound,
|
|
||||||
internalError,
|
|
||||||
} from "./api-error.js";
|
|
||||||
|
|
||||||
/**
|
function isRouteResponse(value: unknown): value is { status: number; body?: unknown } {
|
||||||
* Re-throws an error as an ApiError, converting unknown errors to internal errors.
|
return (
|
||||||
* This is used in route handlers to ensure all errors are properly typed.
|
typeof value === "object"
|
||||||
*/
|
&& value !== null
|
||||||
function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server error"): never {
|
&& "status" in value
|
||||||
if (error instanceof ApiError) throw error;
|
&& typeof (value as { status?: unknown }).status === "number"
|
||||||
if (error instanceof Error) throw new ApiError(500, error.message);
|
);
|
||||||
throw new ApiError(500, fallbackMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
import {
|
async function buildContext(store: TaskStore): Promise<PluginContext> {
|
||||||
generateMilestoneSuggestions,
|
const createAiSession = await getCreateAiSessionFactory();
|
||||||
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";
|
|
||||||
|
|
||||||
// ── Validation Utilities ──────────────────────────────────────────────────────
|
return {
|
||||||
|
pluginId: "fusion-plugin-roadmap",
|
||||||
function validateTitle(title: unknown): string {
|
taskStore: store,
|
||||||
if (!title || typeof title !== "string" || !title.trim()) {
|
settings: {},
|
||||||
throw badRequest("title is required");
|
logger: {
|
||||||
}
|
info: () => {},
|
||||||
if (title.length > 200) {
|
warn: () => {},
|
||||||
throw badRequest("title must not exceed 200 characters");
|
error: () => {},
|
||||||
}
|
debug: () => {},
|
||||||
return title.trim();
|
},
|
||||||
|
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 {
|
export function createRoadmapRouter(store: TaskStore): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const requestContext = new AsyncLocalStorage<TaskStore>();
|
const routes = createRoadmapPluginRoutes();
|
||||||
|
|
||||||
function getProjectIdFromRequest(req: Request): string | undefined {
|
for (const route of routes) {
|
||||||
if (typeof req.query.projectId === "string" && req.query.projectId.trim()) {
|
const handler = async (req: Request, res: Response) => {
|
||||||
return req.query.projectId;
|
const result = await route.handler(req, await buildContext(store));
|
||||||
}
|
if (isRouteResponse(result)) {
|
||||||
if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string" && req.body.projectId.trim()) {
|
if (result.status === 204) {
|
||||||
return req.body.projectId;
|
res.status(204).send();
|
||||||
}
|
|
||||||
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 });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw err;
|
if (result.body === undefined) {
|
||||||
}
|
res.status(result.status).send();
|
||||||
} 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 });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw err;
|
res.status(result.status).json(result.body);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
res.status(200).json(result);
|
||||||
if (err instanceof ApiError) throw err;
|
};
|
||||||
rethrowAsApiError(err, "Failed to generate feature suggestions");
|
|
||||||
} finally {
|
|
||||||
if (routeTimeoutId) clearTimeout(routeTimeoutId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Export / Handoff Endpoints ──────────────────────────────────────────
|
registerRoute(router, route, handler, normalizeLegacyRoadmapPath(route.path));
|
||||||
|
}
|
||||||
/**
|
|
||||||
* 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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
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 @@
|
|||||||
/**
|
export {
|
||||||
* Roadmap Milestone Suggestion Generation Service
|
FEATURE_SUGGESTION_SYSTEM_PROMPT,
|
||||||
*
|
MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||||
* Provides AI-powered milestone suggestion generation for roadmaps.
|
ParseError,
|
||||||
* Users can generate milestone ideas from a goal prompt and accept them
|
ServiceUnavailableError,
|
||||||
* into their roadmap.
|
SUGGESTION_TIMEOUT_MS,
|
||||||
*
|
ValidationError,
|
||||||
* Features:
|
__resetSuggestionState,
|
||||||
* - AI agent integration via dynamic import of @fusion/engine
|
__setCreateAiSessionFactory,
|
||||||
* - Planning-style JSON extraction with repair
|
__setCreateFnAgent,
|
||||||
* - Input validation (goal prompt max length, count bounds)
|
generateFeatureSuggestions,
|
||||||
* - Read-only endpoint (no persistence of suggestions)
|
generateMilestoneSuggestions,
|
||||||
* - Error mapping (validation 400, not found 404, AI/parser 500/503)
|
validateFeatureSuggestionInput,
|
||||||
*/
|
validateSuggestionInput,
|
||||||
|
} from "../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import type { Router } from "express";
|
|||||||
import type { TaskStore } from "@fusion/core";
|
import type { TaskStore } from "@fusion/core";
|
||||||
import type { ServerOptions } from "../server.js";
|
import type { ServerOptions } from "../server.js";
|
||||||
import { createMissionRouter } from "../mission-routes.js";
|
import { createMissionRouter } from "../mission-routes.js";
|
||||||
import { createRoadmapRouter } from "../roadmap-routes.js";
|
|
||||||
import { createInsightsRouter } from "../insights-routes.js";
|
import { createInsightsRouter } from "../insights-routes.js";
|
||||||
import { createEvalsRouter } from "../evals-routes.js";
|
import { createEvalsRouter } from "../evals-routes.js";
|
||||||
import { createResearchRouter } from "../research-routes.js";
|
import { createResearchRouter } from "../research-routes.js";
|
||||||
import { createTodoRouter } from "../todo-routes.js";
|
import { createTodoRouter } from "../todo-routes.js";
|
||||||
|
import { createRoadmapRouter } from "../roadmap-routes.js";
|
||||||
import { createDevServerRouter } from "../dev-server-routes.js";
|
import { createDevServerRouter } from "../dev-server-routes.js";
|
||||||
import type { AiSessionStore } from "../ai-session-store.js";
|
import type { AiSessionStore } from "../ai-session-store.js";
|
||||||
|
|
||||||
@@ -33,11 +33,11 @@ export function registerIntegratedRouters({
|
|||||||
createMissionRouter(store, options?.missionAutopilot, aiSessionStore, options?.missionExecutionLoop, options?.engineManager),
|
createMissionRouter(store, options?.missionAutopilot, aiSessionStore, options?.missionExecutionLoop, options?.engineManager),
|
||||||
);
|
);
|
||||||
|
|
||||||
router.use("/roadmaps", createRoadmapRouter(store));
|
|
||||||
router.use("/insights", createInsightsRouter(store));
|
router.use("/insights", createInsightsRouter(store));
|
||||||
router.use("/evals", createEvalsRouter(store));
|
router.use("/evals", createEvalsRouter(store));
|
||||||
router.use("/research", createResearchRouter(store));
|
router.use("/research", createResearchRouter(store));
|
||||||
router.use("/todos", createTodoRouter(store));
|
router.use("/todos", createTodoRouter(store));
|
||||||
|
router.use("/roadmaps", createRoadmapRouter(store));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {
|
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {
|
||||||
|
|||||||
@@ -10,12 +10,16 @@
|
|||||||
"import": "./src/index.ts"
|
"import": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"./server": {
|
"./server": {
|
||||||
"types": "./src/server/index.ts",
|
"types": "./src/server/index.d.ts",
|
||||||
"import": "./src/server/index.ts"
|
"import": "./src/server/index.ts"
|
||||||
},
|
},
|
||||||
"./dashboard-view": {
|
"./dashboard-view": {
|
||||||
"types": "./src/dashboard-view.ts",
|
"types": "./src/dashboard-view.ts",
|
||||||
"import": "./src/dashboard-view.ts"
|
"import": "./src/dashboard-view.ts"
|
||||||
|
},
|
||||||
|
"./roadmap-suggestions": {
|
||||||
|
"types": "./src/roadmap-suggestions.d.ts",
|
||||||
|
"import": "./src/roadmap-suggestions.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -29,6 +33,7 @@
|
|||||||
"express": "^5.1.0"
|
"express": "^5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.5",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.5.2",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Database } from "@fusion/core";
|
import type { Database } from "@fusion/core";
|
||||||
import { definePlugin } from "@fusion/plugin-sdk";
|
import { definePlugin } from "@fusion/plugin-sdk";
|
||||||
import { createRoadmapPluginRoutes } from "./roadmap-routes.js";
|
import { createRoadmapPluginRoutes } from "./routes/roadmap-routes.js";
|
||||||
|
|
||||||
export function ensureRoadmapSchema(db: Database): void {
|
export function ensureRoadmapSchema(db: Database): void {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
export function createRoadmapPluginRoutes(): [] {
|
export { createRoadmapPluginRoutes, SUGGESTION_TIMEOUT_MS } from "./routes/roadmap-routes.js";
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|||||||
15
plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts
vendored
Normal file
15
plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export {
|
||||||
|
FEATURE_SUGGESTION_SYSTEM_PROMPT,
|
||||||
|
MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||||
|
ParseError,
|
||||||
|
ServiceUnavailableError,
|
||||||
|
SUGGESTION_TIMEOUT_MS,
|
||||||
|
ValidationError,
|
||||||
|
__resetSuggestionState,
|
||||||
|
__setCreateAiSessionFactory,
|
||||||
|
__setCreateFnAgent,
|
||||||
|
generateFeatureSuggestions,
|
||||||
|
generateMilestoneSuggestions,
|
||||||
|
validateFeatureSuggestionInput,
|
||||||
|
validateSuggestionInput,
|
||||||
|
} from "./routes/roadmap-suggestions.js";
|
||||||
@@ -1 +1,15 @@
|
|||||||
export {};
|
export {
|
||||||
|
FEATURE_SUGGESTION_SYSTEM_PROMPT,
|
||||||
|
MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||||
|
ParseError,
|
||||||
|
ServiceUnavailableError,
|
||||||
|
SUGGESTION_TIMEOUT_MS,
|
||||||
|
ValidationError,
|
||||||
|
__resetSuggestionState,
|
||||||
|
__setCreateAiSessionFactory,
|
||||||
|
__setCreateFnAgent,
|
||||||
|
generateFeatureSuggestions,
|
||||||
|
generateMilestoneSuggestions,
|
||||||
|
validateFeatureSuggestionInput,
|
||||||
|
validateSuggestionInput,
|
||||||
|
} from "./routes/roadmap-suggestions.js";
|
||||||
|
|||||||
283
plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts
vendored
Normal file
283
plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts
vendored
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
/**
|
||||||
|
* Standalone roadmap planning types.
|
||||||
|
*
|
||||||
|
* This model is intentionally separate from the mission hierarchy so roadmap
|
||||||
|
* work can evolve independently of `MissionStore`/`MissionManager`.
|
||||||
|
*
|
||||||
|
* Core ordering invariants:
|
||||||
|
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
|
||||||
|
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
|
||||||
|
* - cross-milestone feature moves must renumber both the source and target
|
||||||
|
* milestone deterministically after the move
|
||||||
|
* - whenever stored order data is incomplete or conflicting, consumers should
|
||||||
|
* repair it using a stable tie-breaker (`createdAt`, then `id`, both ASC)
|
||||||
|
*
|
||||||
|
* These contracts are persistence-agnostic and UI-agnostic. They define the
|
||||||
|
* canonical domain surface that downstream storage, API, and dashboard work use.
|
||||||
|
*
|
||||||
|
* @module roadmap-types
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* A standalone roadmap container.
|
||||||
|
*
|
||||||
|
* Roadmaps do not reuse mission lifecycle or mission status concepts. They are
|
||||||
|
* lightweight planning artifacts that own ordered milestones.
|
||||||
|
*/
|
||||||
|
export interface Roadmap {
|
||||||
|
/** Unique identifier (for example `RM-01HXYZ...`) */
|
||||||
|
id: string;
|
||||||
|
/** Display title shown in roadmap lists and detail views */
|
||||||
|
title: string;
|
||||||
|
/** Optional long-form planning context for the roadmap */
|
||||||
|
description?: string;
|
||||||
|
/** ISO-8601 timestamp of creation */
|
||||||
|
createdAt: string;
|
||||||
|
/** ISO-8601 timestamp of last update */
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* A milestone within a roadmap.
|
||||||
|
*
|
||||||
|
* `orderIndex` is the canonical persisted ordering field. It is always scoped to
|
||||||
|
* the parent roadmap and must remain contiguous + 0-based after reorder flows.
|
||||||
|
*/
|
||||||
|
export interface RoadmapMilestone {
|
||||||
|
/** Unique identifier (for example `RMS-01HXYZ...`) */
|
||||||
|
id: string;
|
||||||
|
/** Parent roadmap ID */
|
||||||
|
roadmapId: string;
|
||||||
|
/** Display title for the milestone */
|
||||||
|
title: string;
|
||||||
|
/** Optional description of the milestone's goals */
|
||||||
|
description?: string;
|
||||||
|
/** 0-based contiguous ordering within the roadmap */
|
||||||
|
orderIndex: number;
|
||||||
|
/** ISO-8601 timestamp of creation */
|
||||||
|
createdAt: string;
|
||||||
|
/** ISO-8601 timestamp of last update */
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* A feature within a roadmap milestone.
|
||||||
|
*
|
||||||
|
* `orderIndex` is scoped to the parent milestone. Cross-milestone moves must
|
||||||
|
* update `milestoneId` and then normalize both affected milestone lists back to
|
||||||
|
* contiguous 0-based order.
|
||||||
|
*/
|
||||||
|
export interface RoadmapFeature {
|
||||||
|
/** Unique identifier (for example `RF-01HXYZ...`) */
|
||||||
|
id: string;
|
||||||
|
/** Parent milestone ID */
|
||||||
|
milestoneId: string;
|
||||||
|
/** Display title for the feature */
|
||||||
|
title: string;
|
||||||
|
/** Optional description of the feature's intent */
|
||||||
|
description?: string;
|
||||||
|
/** 0-based contiguous ordering within the parent milestone */
|
||||||
|
orderIndex: number;
|
||||||
|
/** ISO-8601 timestamp of creation */
|
||||||
|
createdAt: string;
|
||||||
|
/** ISO-8601 timestamp of last update */
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/** Input for creating a roadmap. */
|
||||||
|
export interface RoadmapCreateInput {
|
||||||
|
/** Display title of the roadmap (required) */
|
||||||
|
title: string;
|
||||||
|
/** Optional roadmap description */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
/** Input for updating roadmap metadata. Ordering is handled by dedicated move/reorder DTOs. */
|
||||||
|
export interface RoadmapUpdateInput {
|
||||||
|
/** Updated display title */
|
||||||
|
title?: string;
|
||||||
|
/** Updated roadmap description */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
/** Input for creating a milestone inside a roadmap. */
|
||||||
|
export interface RoadmapMilestoneCreateInput {
|
||||||
|
/** Display title of the milestone (required) */
|
||||||
|
title: string;
|
||||||
|
/** Optional milestone description */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
/** Input for updating milestone metadata. Ordering is handled separately. */
|
||||||
|
export interface RoadmapMilestoneUpdateInput {
|
||||||
|
/** Updated milestone title */
|
||||||
|
title?: string;
|
||||||
|
/** Updated milestone description */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
/** Input for creating a feature inside a milestone. */
|
||||||
|
export interface RoadmapFeatureCreateInput {
|
||||||
|
/** Display title of the feature (required) */
|
||||||
|
title: string;
|
||||||
|
/** Optional feature description */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
/** Input for updating feature metadata. Ordering is handled separately. */
|
||||||
|
export interface RoadmapFeatureUpdateInput {
|
||||||
|
/** Updated feature title */
|
||||||
|
title?: string;
|
||||||
|
/** Updated feature description */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Explicit reorder payload for milestones within a roadmap.
|
||||||
|
*
|
||||||
|
* `orderedMilestoneIds` must contain the full set of milestone IDs for the
|
||||||
|
* roadmap exactly once. Consumers should reject partial or duplicate lists.
|
||||||
|
*/
|
||||||
|
export interface RoadmapMilestoneReorderInput {
|
||||||
|
/** Roadmap whose milestone sequence is being rewritten */
|
||||||
|
roadmapId: string;
|
||||||
|
/** Complete milestone ID sequence in final order */
|
||||||
|
orderedMilestoneIds: string[];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Explicit reorder payload for features within a single milestone.
|
||||||
|
*
|
||||||
|
* `orderedFeatureIds` must contain the full set of feature IDs for the milestone
|
||||||
|
* exactly once. The resulting `orderIndex` values must be normalized to 0-based
|
||||||
|
* contiguous order.
|
||||||
|
*/
|
||||||
|
export interface RoadmapFeatureReorderInput {
|
||||||
|
/** Parent roadmap for integrity validation */
|
||||||
|
roadmapId: string;
|
||||||
|
/** Milestone whose internal feature ordering is being rewritten */
|
||||||
|
milestoneId: string;
|
||||||
|
/** Complete feature ID sequence in final order */
|
||||||
|
orderedFeatureIds: string[];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Explicit move payload for relocating a feature, including cross-milestone moves.
|
||||||
|
*
|
||||||
|
* `targetOrderIndex` is the desired insertion position in the destination
|
||||||
|
* milestone before final normalization. Consumers should clamp out-of-range
|
||||||
|
* values and must deterministically renumber both source and destination
|
||||||
|
* milestones after the move.
|
||||||
|
*/
|
||||||
|
export interface RoadmapFeatureMoveInput {
|
||||||
|
/** Parent roadmap for integrity validation */
|
||||||
|
roadmapId: string;
|
||||||
|
/** Feature being moved */
|
||||||
|
featureId: string;
|
||||||
|
/** Current milestone that owns the feature */
|
||||||
|
fromMilestoneId: string;
|
||||||
|
/** Destination milestone after the move */
|
||||||
|
toMilestoneId: string;
|
||||||
|
/** Requested insertion index in the destination milestone */
|
||||||
|
targetOrderIndex: number;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Result of a feature move operation after deterministic renumbering.
|
||||||
|
*
|
||||||
|
* `affectedFeatures` contains the canonical post-move feature records for the
|
||||||
|
* source and target milestones. When a feature is moved within the same
|
||||||
|
* milestone, `sourceMilestoneFeatures` and `targetMilestoneFeatures` will be
|
||||||
|
* the same normalized list.
|
||||||
|
*/
|
||||||
|
export interface RoadmapFeatureMoveResult {
|
||||||
|
/** The moved feature after `milestoneId` and `orderIndex` updates */
|
||||||
|
movedFeature: RoadmapFeature;
|
||||||
|
/** Canonical post-move features for the affected milestone scope */
|
||||||
|
affectedFeatures: RoadmapFeature[];
|
||||||
|
/** Canonical feature list for the source milestone after the move */
|
||||||
|
sourceMilestoneFeatures: RoadmapFeature[];
|
||||||
|
/** Canonical feature list for the destination milestone after the move */
|
||||||
|
targetMilestoneFeatures: RoadmapFeature[];
|
||||||
|
}
|
||||||
|
/** Milestone with all of its ordered features loaded. */
|
||||||
|
export interface RoadmapMilestoneWithFeatures extends RoadmapMilestone {
|
||||||
|
/** Features belonging to this milestone */
|
||||||
|
features: RoadmapFeature[];
|
||||||
|
}
|
||||||
|
/** Full roadmap hierarchy loaded in roadmap → milestone → feature order. */
|
||||||
|
export interface RoadmapWithHierarchy extends Roadmap {
|
||||||
|
/** Ordered milestones with ordered features */
|
||||||
|
milestones: RoadmapMilestoneWithFeatures[];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Flat export payload for persistence, APIs, import/export, and sync jobs.
|
||||||
|
*
|
||||||
|
* This shape intentionally keeps entities separate so downstream persistence
|
||||||
|
* layers can upsert by table/collection without first denormalizing a nested
|
||||||
|
* hierarchy.
|
||||||
|
*/
|
||||||
|
export interface RoadmapExportBundle {
|
||||||
|
/** Roadmap being exported */
|
||||||
|
roadmap: Roadmap;
|
||||||
|
/** Ordered milestones for the roadmap */
|
||||||
|
milestones: RoadmapMilestone[];
|
||||||
|
/** Ordered features for the roadmap's milestones */
|
||||||
|
features: RoadmapFeature[];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Source metadata carried forward when a roadmap feature is converted into a
|
||||||
|
* task-planning input or other downstream artifact.
|
||||||
|
*/
|
||||||
|
export interface RoadmapFeatureSourceRef {
|
||||||
|
/** Source roadmap ID */
|
||||||
|
roadmapId: string;
|
||||||
|
/** Source milestone ID */
|
||||||
|
milestoneId: string;
|
||||||
|
/** Source feature ID */
|
||||||
|
featureId: string;
|
||||||
|
/** Human-readable roadmap title for prompt context */
|
||||||
|
roadmapTitle: string;
|
||||||
|
/** Human-readable milestone title for prompt context */
|
||||||
|
milestoneTitle: string;
|
||||||
|
/** Canonical milestone order at handoff time */
|
||||||
|
milestoneOrderIndex: number;
|
||||||
|
/** Canonical feature order at handoff time */
|
||||||
|
featureOrderIndex: number;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handoff payload for converting a single roadmap feature into task planning
|
||||||
|
* flows without coupling the task system to roadmap persistence details.
|
||||||
|
*/
|
||||||
|
export interface RoadmapFeatureTaskPlanningHandoff {
|
||||||
|
/** Source lineage and ordering context */
|
||||||
|
source: RoadmapFeatureSourceRef;
|
||||||
|
/** Title to seed the downstream task or planning prompt */
|
||||||
|
title: string;
|
||||||
|
/** Optional description to seed the downstream task or planning prompt */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
/** Source-preserving milestone payload used for mission conversion handoffs. */
|
||||||
|
export interface RoadmapMissionPlanningMilestoneHandoff {
|
||||||
|
/** Source roadmap milestone ID */
|
||||||
|
sourceMilestoneId: string;
|
||||||
|
/** Canonical milestone title */
|
||||||
|
title: string;
|
||||||
|
/** Optional milestone description */
|
||||||
|
description?: string;
|
||||||
|
/** Canonical milestone ordering within the roadmap */
|
||||||
|
orderIndex: number;
|
||||||
|
/** Ordered roadmap features that belong to this milestone */
|
||||||
|
features: Array<{
|
||||||
|
/** Source roadmap feature ID */
|
||||||
|
sourceFeatureId: string;
|
||||||
|
/** Canonical feature title */
|
||||||
|
title: string;
|
||||||
|
/** Optional feature description */
|
||||||
|
description?: string;
|
||||||
|
/** Canonical feature ordering within the milestone */
|
||||||
|
orderIndex: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handoff payload for converting a standalone roadmap into mission planning
|
||||||
|
* structures while preserving source IDs and deterministic order.
|
||||||
|
*/
|
||||||
|
export interface RoadmapMissionPlanningHandoff {
|
||||||
|
/** Source roadmap ID */
|
||||||
|
sourceRoadmapId: string;
|
||||||
|
/** Canonical roadmap title */
|
||||||
|
title: string;
|
||||||
|
/** Optional roadmap description */
|
||||||
|
description?: string;
|
||||||
|
/** Ordered milestone breakdown captured at handoff time */
|
||||||
|
milestones: RoadmapMissionPlanningMilestoneHandoff[];
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=roadmap-types.d.ts.map
|
||||||
1
plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map
Normal file
1
plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"roadmap-types.d.ts","sourceRoot":"","sources":["roadmap-types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,qDAAqD;IACrD,EAAE,EAAE,MAAM,CAAC;IACX,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAC;IACd,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qCAAqC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,sDAAsD;IACtD,EAAE,EAAE,MAAM,CAAC;IACX,wBAAwB;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qDAAqD;IACrD,UAAU,EAAE,MAAM,CAAC;IACnB,qCAAqC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,cAAc;IAC7B,qDAAqD;IACrD,EAAE,EAAE,MAAM,CAAC;IACX,0BAA0B;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,oCAAoC;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8DAA8D;IAC9D,UAAU,EAAE,MAAM,CAAC;IACnB,qCAAqC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;CACnB;AAID,oCAAoC;AACpC,MAAM,WAAW,kBAAkB;IACjC,8CAA8C;IAC9C,KAAK,EAAE,MAAM,CAAC;IACd,mCAAmC;IACnC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,+FAA+F;AAC/F,MAAM,WAAW,kBAAkB;IACjC,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kCAAkC;IAClC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,uDAAuD;AACvD,MAAM,WAAW,2BAA2B;IAC1C,gDAAgD;IAChD,KAAK,EAAE,MAAM,CAAC;IACd,qCAAqC;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,6EAA6E;AAC7E,MAAM,WAAW,2BAA2B;IAC1C,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,uDAAuD;AACvD,MAAM,WAAW,yBAAyB;IACxC,8CAA8C;IAC9C,KAAK,EAAE,MAAM,CAAC;IACd,mCAAmC;IACnC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,2EAA2E;AAC3E,MAAM,WAAW,yBAAyB;IACxC,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kCAAkC;IAClC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAID;;;;;GAKG;AACH,MAAM,WAAW,4BAA4B;IAC3C,0DAA0D;IAC1D,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,mBAAmB,EAAE,MAAM,EAAE,CAAC;CAC/B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,0BAA0B;IACzC,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,WAAW,EAAE,MAAM,CAAC;IACpB,kDAAkD;IAClD,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,uBAAuB;IACtC,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,0BAA0B;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,eAAe,EAAE,MAAM,CAAC;IACxB,2CAA2C;IAC3C,aAAa,EAAE,MAAM,CAAC;IACtB,6DAA6D;IAC7D,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,wBAAwB;IACvC,qEAAqE;IACrE,YAAY,EAAE,cAAc,CAAC;IAC7B,oEAAoE;IACpE,gBAAgB,EAAE,cAAc,EAAE,CAAC;IACnC,qEAAqE;IACrE,uBAAuB,EAAE,cAAc,EAAE,CAAC;IAC1C,0EAA0E;IAC1E,uBAAuB,EAAE,cAAc,EAAE,CAAC;CAC3C;AAID,yDAAyD;AACzD,MAAM,WAAW,4BAA6B,SAAQ,gBAAgB;IACpE,2CAA2C;IAC3C,QAAQ,EAAE,cAAc,EAAE,CAAC;CAC5B;AAED,4EAA4E;AAC5E,MAAM,WAAW,oBAAqB,SAAQ,OAAO;IACnD,+CAA+C;IAC/C,UAAU,EAAE,4BAA4B,EAAE,CAAC;CAC5C;AAID;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC,6BAA6B;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,yCAAyC;IACzC,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B,oDAAoD;IACpD,QAAQ,EAAE,cAAc,EAAE,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,WAAW,uBAAuB;IACtC,wBAAwB;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,0BAA0B;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,wBAAwB;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,sDAAsD;IACtD,YAAY,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,cAAc,EAAE,MAAM,CAAC;IACvB,gDAAgD;IAChD,mBAAmB,EAAE,MAAM,CAAC;IAC5B,8CAA8C;IAC9C,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED;;;GAGG;AACH,MAAM,WAAW,iCAAiC;IAChD,0CAA0C;IAC1C,MAAM,EAAE,uBAAuB,CAAC;IAChC,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAC;IACd,0EAA0E;IAC1E,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,gFAAgF;AAChF,MAAM,WAAW,sCAAsC;IACrD,kCAAkC;IAClC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,qCAAqC;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sDAAsD;IACtD,UAAU,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,QAAQ,EAAE,KAAK,CAAC;QACd,gCAAgC;QAChC,eAAe,EAAE,MAAM,CAAC;QACxB,8BAA8B;QAC9B,KAAK,EAAE,MAAM,CAAC;QACd,mCAAmC;QACnC,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,sDAAsD;QACtD,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC,CAAC;CACJ;AAED;;;GAGG;AACH,MAAM,WAAW,6BAA6B;IAC5C,wBAAwB;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,8BAA8B;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,mCAAmC;IACnC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,UAAU,EAAE,sCAAsC,EAAE,CAAC;CACtD"}
|
||||||
21
plugins/fusion-plugin-roadmap/src/roadmap-types.js
Normal file
21
plugins/fusion-plugin-roadmap/src/roadmap-types.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Standalone roadmap planning types.
|
||||||
|
*
|
||||||
|
* This model is intentionally separate from the mission hierarchy so roadmap
|
||||||
|
* work can evolve independently of `MissionStore`/`MissionManager`.
|
||||||
|
*
|
||||||
|
* Core ordering invariants:
|
||||||
|
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
|
||||||
|
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
|
||||||
|
* - cross-milestone feature moves must renumber both the source and target
|
||||||
|
* milestone deterministically after the move
|
||||||
|
* - whenever stored order data is incomplete or conflicting, consumers should
|
||||||
|
* repair it using a stable tie-breaker (`createdAt`, then `id`, both ASC)
|
||||||
|
*
|
||||||
|
* These contracts are persistence-agnostic and UI-agnostic. They define the
|
||||||
|
* canonical domain surface that downstream storage, API, and dashboard work use.
|
||||||
|
*
|
||||||
|
* @module roadmap-types
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
|
//# sourceMappingURL=roadmap-types.js.map
|
||||||
1
plugins/fusion-plugin-roadmap/src/roadmap-types.js.map
Normal file
1
plugins/fusion-plugin-roadmap/src/roadmap-types.js.map
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"roadmap-types.js","sourceRoot":"","sources":["roadmap-types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG"}
|
||||||
4
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts
vendored
Normal file
4
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { PluginRouteDefinition } from "@fusion/core";
|
||||||
|
export declare function createRoadmapPluginRoutes(): PluginRouteDefinition[];
|
||||||
|
export { SUGGESTION_TIMEOUT_MS };
|
||||||
|
//# sourceMappingURL=roadmap-routes.d.ts.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"roadmap-routes.d.ts","sourceRoot":"","sources":["roadmap-routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,qBAAqB,EAAuB,MAAM,cAAc,CAAC;AAiG9F,wBAAgB,yBAAyB,IAAI,qBAAqB,EAAE,CAyRnE;AAED,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
|
||||||
361
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js
Normal file
361
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import { RoadmapStore } from "../store/roadmap-store.js";
|
||||||
|
import { generateFeatureSuggestions, generateMilestoneSuggestions, ParseError as SuggestionParseError, ServiceUnavailableError as SuggestionServiceUnavailableError, validateFeatureSuggestionInput, validateSuggestionInput, ValidationError as SuggestionValidationError, } from "./roadmap-suggestions.js";
|
||||||
|
const roadmapStoreCache = new WeakMap();
|
||||||
|
function getRoadmapStore(ctx) {
|
||||||
|
const taskStoreWithRoadmaps = ctx.taskStore;
|
||||||
|
if (typeof taskStoreWithRoadmaps.getRoadmapStore === "function") {
|
||||||
|
return taskStoreWithRoadmaps.getRoadmapStore();
|
||||||
|
}
|
||||||
|
const key = ctx.taskStore;
|
||||||
|
const cached = roadmapStoreCache.get(key);
|
||||||
|
if (cached)
|
||||||
|
return cached;
|
||||||
|
const store = new RoadmapStore(ctx.taskStore.getDatabase());
|
||||||
|
roadmapStoreCache.set(key, store);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
function asRequest(req) {
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
function badRequest(message) {
|
||||||
|
return { status: 400, body: { error: message } };
|
||||||
|
}
|
||||||
|
function notFound(message) {
|
||||||
|
return { status: 404, body: { error: message } };
|
||||||
|
}
|
||||||
|
function serverError(message) {
|
||||||
|
return { status: 500, body: { error: message } };
|
||||||
|
}
|
||||||
|
function noContent() {
|
||||||
|
return { status: 204 };
|
||||||
|
}
|
||||||
|
function routeHandler(handler) {
|
||||||
|
return async (req, ctx) => {
|
||||||
|
const roadmapStore = getRoadmapStore(ctx);
|
||||||
|
try {
|
||||||
|
return await handler(asRequest(req), ctx, roadmapStore);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error instanceof Error && error.message.toLowerCase().includes("not found")) {
|
||||||
|
return notFound(error.message);
|
||||||
|
}
|
||||||
|
return serverError(error instanceof Error ? error.message : "Internal server error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function validateTitle(title) {
|
||||||
|
if (!title || typeof title !== "string" || !title.trim()) {
|
||||||
|
throw new Error("title is required");
|
||||||
|
}
|
||||||
|
if (title.length > 200) {
|
||||||
|
throw new Error("title must not exceed 200 characters");
|
||||||
|
}
|
||||||
|
return title.trim();
|
||||||
|
}
|
||||||
|
function validateDescription(desc) {
|
||||||
|
if (desc === undefined || desc === null)
|
||||||
|
return undefined;
|
||||||
|
if (typeof desc !== "string") {
|
||||||
|
throw new Error("description must be a string");
|
||||||
|
}
|
||||||
|
if (desc.length > 5000) {
|
||||||
|
throw new Error("description must not exceed 5000 characters");
|
||||||
|
}
|
||||||
|
return desc.trim() || undefined;
|
||||||
|
}
|
||||||
|
function validateStringArray(arr, fieldName) {
|
||||||
|
if (!Array.isArray(arr)) {
|
||||||
|
throw new Error(`${fieldName} must be an array`);
|
||||||
|
}
|
||||||
|
if (!arr.every((item) => typeof item === "string")) {
|
||||||
|
throw new Error(`${fieldName} must be an array of strings`);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
export function createRoadmapPluginRoutes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/roadmaps",
|
||||||
|
handler: routeHandler((_req, _ctx, roadmapStore) => roadmapStore.listRoadmaps()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body;
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: roadmapStore.createRoadmap({
|
||||||
|
title: validateTitle(body?.title),
|
||||||
|
description: validateDescription(body?.description),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/roadmaps/:roadmapId",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const roadmap = roadmapStore.getRoadmapWithHierarchy(req.params.roadmapId);
|
||||||
|
return roadmap ? roadmap : notFound(`Roadmap ${req.params.roadmapId} not found`);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/roadmaps/:roadmapId",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body;
|
||||||
|
try {
|
||||||
|
return roadmapStore.updateRoadmap(req.params.roadmapId, {
|
||||||
|
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||||
|
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
roadmapStore.deleteRoadmap(req.params.roadmapId);
|
||||||
|
return noContent();
|
||||||
|
}) },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/:roadmapId/milestones",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body;
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: roadmapStore.createMilestone(req.params.roadmapId, {
|
||||||
|
title: validateTitle(body?.title),
|
||||||
|
description: validateDescription(body?.description),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/:roadmapId/milestones/reorder",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
try {
|
||||||
|
const body = req.body;
|
||||||
|
roadmapStore.reorderMilestones({ roadmapId: req.params.roadmapId, orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") });
|
||||||
|
return noContent();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/roadmaps/milestones/:milestoneId",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body;
|
||||||
|
try {
|
||||||
|
return roadmapStore.updateMilestone(req.params.milestoneId, {
|
||||||
|
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||||
|
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
roadmapStore.deleteMilestone(req.params.milestoneId);
|
||||||
|
return noContent();
|
||||||
|
}) },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/milestones/:milestoneId/features",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body;
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: roadmapStore.createFeature(req.params.milestoneId, {
|
||||||
|
title: validateTitle(body?.title),
|
||||||
|
description: validateDescription(body?.description),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/milestones/:milestoneId/features/reorder",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
try {
|
||||||
|
const body = req.body;
|
||||||
|
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
||||||
|
if (!milestone)
|
||||||
|
return notFound(`Milestone ${req.params.milestoneId} not found`);
|
||||||
|
roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: req.params.milestoneId, orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") });
|
||||||
|
return noContent();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/roadmaps/features/:featureId",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body;
|
||||||
|
try {
|
||||||
|
return roadmapStore.updateFeature(req.params.featureId, {
|
||||||
|
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||||
|
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
roadmapStore.deleteFeature(req.params.featureId);
|
||||||
|
return noContent();
|
||||||
|
}) },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/features/:featureId/move",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body;
|
||||||
|
if (!body?.targetMilestoneId)
|
||||||
|
return badRequest("targetMilestoneId is required");
|
||||||
|
if (typeof body.targetIndex !== "number")
|
||||||
|
return badRequest("targetIndex must be a number");
|
||||||
|
const feature = roadmapStore.getFeature(req.params.featureId);
|
||||||
|
if (!feature)
|
||||||
|
return notFound(`Feature ${req.params.featureId} not found`);
|
||||||
|
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
|
||||||
|
if (!fromMilestone)
|
||||||
|
return notFound(`Source milestone ${feature.milestoneId} not found`);
|
||||||
|
const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId);
|
||||||
|
if (!toMilestone)
|
||||||
|
return notFound(`Target milestone ${body.targetMilestoneId} not found`);
|
||||||
|
roadmapStore.moveFeature({
|
||||||
|
roadmapId: fromMilestone.roadmapId,
|
||||||
|
featureId: req.params.featureId,
|
||||||
|
fromMilestoneId: feature.milestoneId,
|
||||||
|
toMilestoneId: body.targetMilestoneId,
|
||||||
|
targetOrderIndex: body.targetIndex,
|
||||||
|
});
|
||||||
|
return noContent();
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/:roadmapId/suggestions/milestones",
|
||||||
|
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||||
|
const roadmap = roadmapStore.getRoadmap(req.params.roadmapId);
|
||||||
|
if (!roadmap)
|
||||||
|
return notFound(`Roadmap ${req.params.roadmapId} not found`);
|
||||||
|
try {
|
||||||
|
validateSuggestionInput(req.body);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error instanceof SuggestionValidationError)
|
||||||
|
return badRequest(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = req.body;
|
||||||
|
const suggestions = await generateMilestoneSuggestions(body.goalPrompt, body.count, ctx.taskStore.getRootDir(), undefined, undefined, ctx.createAiSession);
|
||||||
|
return { suggestions };
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error instanceof SuggestionParseError)
|
||||||
|
return serverError(error.message);
|
||||||
|
if (error instanceof SuggestionServiceUnavailableError) {
|
||||||
|
return { status: 503, body: { error: error.message } };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/milestones/:milestoneId/suggestions/features",
|
||||||
|
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||||
|
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
||||||
|
if (!milestone)
|
||||||
|
return notFound(`Milestone ${req.params.milestoneId} not found`);
|
||||||
|
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
|
||||||
|
if (!roadmap)
|
||||||
|
return notFound(`Roadmap ${milestone.roadmapId} not found`);
|
||||||
|
try {
|
||||||
|
validateFeatureSuggestionInput(req.body);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error instanceof SuggestionValidationError)
|
||||||
|
return badRequest(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = req.body;
|
||||||
|
const suggestions = await generateFeatureSuggestions({
|
||||||
|
roadmapTitle: roadmap.title,
|
||||||
|
roadmapDescription: roadmap.description,
|
||||||
|
milestoneTitle: milestone.title,
|
||||||
|
milestoneDescription: milestone.description,
|
||||||
|
existingFeatureTitles: roadmapStore.listFeatures(milestone.id).map((feature) => feature.title),
|
||||||
|
}, body.count, body.prompt, ctx.taskStore.getRootDir(), undefined, undefined, ctx.createAiSession);
|
||||||
|
return { suggestions };
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error instanceof SuggestionParseError)
|
||||||
|
return serverError(error.message);
|
||||||
|
if (error instanceof SuggestionServiceUnavailableError) {
|
||||||
|
return { status: 503, body: { error: error.message } };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/roadmaps/:roadmapId/export",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapExport(req.params.roadmapId)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/roadmaps/:roadmapId/handoff",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => ({
|
||||||
|
mission: roadmapStore.getMissionPlanningHandoff(req.params.roadmapId),
|
||||||
|
features: roadmapStore.listFeatureTaskPlanningHandoffs(req.params.roadmapId),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/roadmaps/:roadmapId/handoff/mission",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getMissionPlanningHandoff(req.params.roadmapId)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapFeatureHandoff(req.params.roadmapId, req.params.milestoneId, req.params.featureId)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
export { SUGGESTION_TIMEOUT_MS } from "./roadmap-suggestions.js";
|
||||||
|
//# sourceMappingURL=roadmap-routes.js.map
|
||||||
File diff suppressed because one or more lines are too long
381
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts
Normal file
381
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { RoadmapStore } from "../store/roadmap-store.js";
|
||||||
|
import {
|
||||||
|
generateFeatureSuggestions,
|
||||||
|
generateMilestoneSuggestions,
|
||||||
|
ParseError as SuggestionParseError,
|
||||||
|
ServiceUnavailableError as SuggestionServiceUnavailableError,
|
||||||
|
validateFeatureSuggestionInput,
|
||||||
|
validateSuggestionInput,
|
||||||
|
ValidationError as SuggestionValidationError,
|
||||||
|
} from "./roadmap-suggestions.js";
|
||||||
|
|
||||||
|
const roadmapStoreCache = new WeakMap<object, RoadmapStore>();
|
||||||
|
|
||||||
|
function getRoadmapStore(ctx: PluginContext): RoadmapStore {
|
||||||
|
const taskStoreWithRoadmaps = ctx.taskStore as PluginContext["taskStore"] & {
|
||||||
|
getRoadmapStore?: () => RoadmapStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof taskStoreWithRoadmaps.getRoadmapStore === "function") {
|
||||||
|
return taskStoreWithRoadmaps.getRoadmapStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = ctx.taskStore as object;
|
||||||
|
const cached = roadmapStoreCache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
const store = new RoadmapStore(ctx.taskStore.getDatabase());
|
||||||
|
roadmapStoreCache.set(key, store);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRequest(req: unknown): Request {
|
||||||
|
return req as Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function badRequest(message: string): PluginRouteResponse {
|
||||||
|
return { status: 400, body: { error: message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound(message: string): PluginRouteResponse {
|
||||||
|
return { status: 404, body: { error: message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverError(message: string): PluginRouteResponse {
|
||||||
|
return { status: 500, body: { error: message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function noContent(): PluginRouteResponse {
|
||||||
|
return { status: 204 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeHandler<T>(handler: (req: Request, ctx: PluginContext, roadmapStore: RoadmapStore) => Promise<T | PluginRouteResponse> | T | PluginRouteResponse) {
|
||||||
|
return async (req: unknown, ctx: PluginContext): Promise<T | PluginRouteResponse> => {
|
||||||
|
const roadmapStore = getRoadmapStore(ctx);
|
||||||
|
try {
|
||||||
|
return await handler(asRequest(req), ctx, roadmapStore);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.toLowerCase().includes("not found")) {
|
||||||
|
return notFound(error.message);
|
||||||
|
}
|
||||||
|
return serverError(error instanceof Error ? error.message : "Internal server error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTitle(title: unknown): string {
|
||||||
|
if (!title || typeof title !== "string" || !title.trim()) {
|
||||||
|
throw new Error("title is required");
|
||||||
|
}
|
||||||
|
if (title.length > 200) {
|
||||||
|
throw new Error("title must not exceed 200 characters");
|
||||||
|
}
|
||||||
|
return title.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDescription(desc: unknown): string | undefined {
|
||||||
|
if (desc === undefined || desc === null) return undefined;
|
||||||
|
if (typeof desc !== "string") {
|
||||||
|
throw new Error("description must be a string");
|
||||||
|
}
|
||||||
|
if (desc.length > 5000) {
|
||||||
|
throw new Error("description must not exceed 5000 characters");
|
||||||
|
}
|
||||||
|
return desc.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStringArray(arr: unknown, fieldName: string): string[] {
|
||||||
|
if (!Array.isArray(arr)) {
|
||||||
|
throw new Error(`${fieldName} must be an array`);
|
||||||
|
}
|
||||||
|
if (!arr.every((item) => typeof item === "string")) {
|
||||||
|
throw new Error(`${fieldName} must be an array of strings`);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/roadmaps",
|
||||||
|
handler: routeHandler((_req, _ctx, roadmapStore) => roadmapStore.listRoadmaps()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body as { title: string; description?: string };
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: roadmapStore.createRoadmap({
|
||||||
|
title: validateTitle(body?.title),
|
||||||
|
description: validateDescription(body?.description),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/roadmaps/:roadmapId",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const roadmap = roadmapStore.getRoadmapWithHierarchy(req.params.roadmapId);
|
||||||
|
return roadmap ? roadmap : notFound(`Roadmap ${req.params.roadmapId} not found`);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/roadmaps/:roadmapId",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body as { title?: string; description?: string };
|
||||||
|
try {
|
||||||
|
return roadmapStore.updateRoadmap(req.params.roadmapId, {
|
||||||
|
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||||
|
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
roadmapStore.deleteRoadmap(req.params.roadmapId);
|
||||||
|
return noContent();
|
||||||
|
}) },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/:roadmapId/milestones",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body as { title: string; description?: string };
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: roadmapStore.createMilestone(req.params.roadmapId, {
|
||||||
|
title: validateTitle(body?.title),
|
||||||
|
description: validateDescription(body?.description),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/:roadmapId/milestones/reorder",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
try {
|
||||||
|
const body = req.body as { orderedMilestoneIds: string[] };
|
||||||
|
roadmapStore.reorderMilestones({ roadmapId: req.params.roadmapId, orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") });
|
||||||
|
return noContent();
|
||||||
|
} catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/roadmaps/milestones/:milestoneId",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body as { title?: string; description?: string };
|
||||||
|
try {
|
||||||
|
return roadmapStore.updateMilestone(req.params.milestoneId, {
|
||||||
|
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||||
|
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
roadmapStore.deleteMilestone(req.params.milestoneId);
|
||||||
|
return noContent();
|
||||||
|
}) },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/milestones/:milestoneId/features",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body as { title: string; description?: string };
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: roadmapStore.createFeature(req.params.milestoneId, {
|
||||||
|
title: validateTitle(body?.title),
|
||||||
|
description: validateDescription(body?.description),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/milestones/:milestoneId/features/reorder",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
try {
|
||||||
|
const body = req.body as { orderedFeatureIds: string[] };
|
||||||
|
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
||||||
|
if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`);
|
||||||
|
roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: req.params.milestoneId, orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") });
|
||||||
|
return noContent();
|
||||||
|
} catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/roadmaps/features/:featureId",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body as { title?: string; description?: string };
|
||||||
|
try {
|
||||||
|
return roadmapStore.updateFeature(req.params.featureId, {
|
||||||
|
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||||
|
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
roadmapStore.deleteFeature(req.params.featureId);
|
||||||
|
return noContent();
|
||||||
|
}) },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/features/:featureId/move",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
|
const body = req.body as { targetMilestoneId: string; targetIndex: number };
|
||||||
|
if (!body?.targetMilestoneId) return badRequest("targetMilestoneId is required");
|
||||||
|
if (typeof body.targetIndex !== "number") return badRequest("targetIndex must be a number");
|
||||||
|
|
||||||
|
const feature = roadmapStore.getFeature(req.params.featureId);
|
||||||
|
if (!feature) return notFound(`Feature ${req.params.featureId} not found`);
|
||||||
|
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
|
||||||
|
if (!fromMilestone) return notFound(`Source milestone ${feature.milestoneId} not found`);
|
||||||
|
const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId);
|
||||||
|
if (!toMilestone) return notFound(`Target milestone ${body.targetMilestoneId} not found`);
|
||||||
|
|
||||||
|
roadmapStore.moveFeature({
|
||||||
|
roadmapId: fromMilestone.roadmapId,
|
||||||
|
featureId: req.params.featureId,
|
||||||
|
fromMilestoneId: feature.milestoneId,
|
||||||
|
toMilestoneId: body.targetMilestoneId,
|
||||||
|
targetOrderIndex: body.targetIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
return noContent();
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/:roadmapId/suggestions/milestones",
|
||||||
|
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||||
|
const roadmap = roadmapStore.getRoadmap(req.params.roadmapId);
|
||||||
|
if (!roadmap) return notFound(`Roadmap ${req.params.roadmapId} not found`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateSuggestionInput(req.body);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SuggestionValidationError) return badRequest(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = req.body as { goalPrompt: string; count?: number };
|
||||||
|
const suggestions = await generateMilestoneSuggestions(
|
||||||
|
body.goalPrompt,
|
||||||
|
body.count,
|
||||||
|
ctx.taskStore.getRootDir(),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
ctx.createAiSession,
|
||||||
|
);
|
||||||
|
return { suggestions };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SuggestionParseError) return serverError(error.message);
|
||||||
|
if (error instanceof SuggestionServiceUnavailableError) {
|
||||||
|
return { status: 503, body: { error: error.message } };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/roadmaps/milestones/:milestoneId/suggestions/features",
|
||||||
|
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||||
|
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
||||||
|
if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`);
|
||||||
|
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
|
||||||
|
if (!roadmap) return notFound(`Roadmap ${milestone.roadmapId} not found`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateFeatureSuggestionInput(req.body);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SuggestionValidationError) return badRequest(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = req.body as { prompt?: string; count?: number };
|
||||||
|
const suggestions = await generateFeatureSuggestions(
|
||||||
|
{
|
||||||
|
roadmapTitle: roadmap.title,
|
||||||
|
roadmapDescription: roadmap.description,
|
||||||
|
milestoneTitle: milestone.title,
|
||||||
|
milestoneDescription: milestone.description,
|
||||||
|
existingFeatureTitles: roadmapStore.listFeatures(milestone.id).map((feature) => feature.title),
|
||||||
|
},
|
||||||
|
body.count,
|
||||||
|
body.prompt,
|
||||||
|
ctx.taskStore.getRootDir(),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
ctx.createAiSession,
|
||||||
|
);
|
||||||
|
return { suggestions };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SuggestionParseError) return serverError(error.message);
|
||||||
|
if (error instanceof SuggestionServiceUnavailableError) {
|
||||||
|
return { status: 503, body: { error: error.message } };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/roadmaps/:roadmapId/export",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapExport(req.params.roadmapId)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/roadmaps/:roadmapId/handoff",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => ({
|
||||||
|
mission: roadmapStore.getMissionPlanningHandoff(req.params.roadmapId),
|
||||||
|
features: roadmapStore.listFeatureTaskPlanningHandoffs(req.params.roadmapId),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/roadmaps/:roadmapId/handoff/mission",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getMissionPlanningHandoff(req.params.roadmapId)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task",
|
||||||
|
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapFeatureHandoff(req.params.roadmapId, req.params.milestoneId, req.params.featureId)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SUGGESTION_TIMEOUT_MS };
|
||||||
68
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts
vendored
Normal file
68
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { CreateAiSessionFactory } from "@fusion/core";
|
||||||
|
type LegacyCreateFnAgent = (options: {
|
||||||
|
cwd: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
tools?: "coding" | "readonly";
|
||||||
|
defaultProvider?: string;
|
||||||
|
defaultModelId?: string;
|
||||||
|
onThinking?: () => void;
|
||||||
|
onText?: () => void;
|
||||||
|
}) => Promise<{
|
||||||
|
session: {
|
||||||
|
prompt(text: string): Promise<void>;
|
||||||
|
state: {
|
||||||
|
messages: Array<{
|
||||||
|
role: string;
|
||||||
|
content?: string | Array<{
|
||||||
|
type: string;
|
||||||
|
text: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
dispose?: () => void;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
export interface GenerateMilestoneSuggestionsInput {
|
||||||
|
goalPrompt: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
export interface MilestoneSuggestion {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
export declare const MILESTONE_SUGGESTION_SYSTEM_PROMPT = "You are a milestone planning assistant for a product roadmap system.\n\nYour job is to suggest logical milestones that would help achieve a user's roadmap goal.\n\n## Guidelines\n\n1. **Think about phases**: Break the goal into logical phases\n2. **Use clear titles**: Milestone titles should be concise and descriptive\n3. **Add context**: Include a brief description explaining what this milestone encompasses\n4. **Order matters**: List milestones in the order they should be completed\n5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks\n\n## Output Format\n\nRespond with ONLY a valid JSON array of milestone suggestions.";
|
||||||
|
export declare const SUGGESTION_TIMEOUT_MS = 120000;
|
||||||
|
export declare function validateSuggestionInput(input: unknown): asserts input is GenerateMilestoneSuggestionsInput;
|
||||||
|
export declare function generateMilestoneSuggestions(goalPrompt: string, count?: number, rootDir?: string, modelProvider?: string, modelId?: string, createAiSession?: CreateAiSessionFactory): Promise<MilestoneSuggestion[]>;
|
||||||
|
export interface GenerateFeatureSuggestionsInput {
|
||||||
|
prompt?: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
export interface FeatureSuggestion {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
export interface FeatureSuggestionContext {
|
||||||
|
roadmapTitle: string;
|
||||||
|
roadmapDescription?: string;
|
||||||
|
milestoneTitle: string;
|
||||||
|
milestoneDescription?: string;
|
||||||
|
existingFeatureTitles: string[];
|
||||||
|
}
|
||||||
|
export declare const FEATURE_SUGGESTION_SYSTEM_PROMPT = "You are a feature planning assistant for a product roadmap system.";
|
||||||
|
export declare function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput;
|
||||||
|
export declare function generateFeatureSuggestions(context: FeatureSuggestionContext, count?: number, prompt?: string, rootDir?: string, modelProvider?: string, modelId?: string, createAiSession?: CreateAiSessionFactory): Promise<FeatureSuggestion[]>;
|
||||||
|
export declare class ValidationError extends Error {
|
||||||
|
constructor(message: string);
|
||||||
|
}
|
||||||
|
export declare class ParseError extends Error {
|
||||||
|
constructor(message: string);
|
||||||
|
}
|
||||||
|
export declare class ServiceUnavailableError extends Error {
|
||||||
|
constructor(message: string);
|
||||||
|
}
|
||||||
|
export declare function __resetSuggestionState(): void;
|
||||||
|
export declare function __setCreateFnAgent(mock: LegacyCreateFnAgent | undefined): void;
|
||||||
|
export declare function __setCreateAiSessionFactory(mock: CreateAiSessionFactory | undefined): void;
|
||||||
|
export {};
|
||||||
|
//# sourceMappingURL=roadmap-suggestions.d.ts.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"roadmap-suggestions.d.ts","sourceRoot":"","sources":["roadmap-suggestions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAE3D,KAAK,mBAAmB,GAAG,CAAC,OAAO,EAAE;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,QAAQ,GAAG,UAAU,CAAC;IAC9B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;CACrB,KAAK,OAAO,CAAC;IACZ,OAAO,EAAE;QACP,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QACpC,KAAK,EAAE;YAAE,QAAQ,EAAE,KAAK,CAAC;gBAAE,IAAI,EAAE,MAAM,CAAC;gBAAC,OAAO,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;oBAAE,IAAI,EAAE,MAAM,CAAC;oBAAC,IAAI,EAAE,MAAM,CAAA;iBAAE,CAAC,CAAA;aAAE,CAAC,CAAA;SAAE,CAAC;QACvG,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;KACtB,CAAC;CACH,CAAC,CAAC;AAIH,MAAM,WAAW,iCAAiC;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,eAAO,MAAM,kCAAkC,6oBAcgB,CAAC;AAGhE,eAAO,MAAM,qBAAqB,SAAU,CAAC;AAM7C,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,iCAAiC,CAiB1G;AAyHD,wBAAsB,4BAA4B,CAChD,UAAU,EAAE,MAAM,EAClB,KAAK,GAAE,MAAiC,EACxC,OAAO,CAAC,EAAE,MAAM,EAChB,aAAa,CAAC,EAAE,MAAM,EACtB,OAAO,CAAC,EAAE,MAAM,EAChB,eAAe,CAAC,EAAE,sBAAsB,GACvC,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAmDhC;AAED,MAAM,WAAW,+BAA+B;IAC9C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,cAAc,EAAE,MAAM,CAAC;IACvB,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,qBAAqB,EAAE,MAAM,EAAE,CAAC;CACjC;AAED,eAAO,MAAM,gCAAgC,uEAAuE,CAAC;AAIrH,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,+BAA+B,CAa/G;AAmBD,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,wBAAwB,EACjC,KAAK,GAAE,MAAiC,EACxC,MAAM,CAAC,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,MAAM,EAChB,aAAa,CAAC,EAAE,MAAM,EACtB,OAAO,CAAC,EAAE,MAAM,EAChB,eAAe,CAAC,EAAE,sBAAsB,GACvC,OAAO,CAAC,iBAAiB,EAAE,CAAC,CA8B9B;AAED,qBAAa,eAAgB,SAAQ,KAAK;gBAC5B,OAAO,EAAE,MAAM;CAI5B;AAED,qBAAa,UAAW,SAAQ,KAAK;gBACvB,OAAO,EAAE,MAAM;CAI5B;AAED,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,OAAO,EAAE,MAAM;CAI5B;AAED,wBAAgB,sBAAsB,IAAI,IAAI,CAE7C;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,mBAAmB,GAAG,SAAS,GAAG,IAAI,CAM9E;AAED,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,sBAAsB,GAAG,SAAS,GAAG,IAAI,CAE1F"}
|
||||||
300
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js
Normal file
300
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
let injectedCreateAiSession;
|
||||||
|
export const MILESTONE_SUGGESTION_SYSTEM_PROMPT = `You are a milestone planning assistant for a product roadmap system.
|
||||||
|
|
||||||
|
Your job is to suggest logical milestones that would help achieve a user's roadmap goal.
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
1. **Think about phases**: Break the goal into logical phases
|
||||||
|
2. **Use clear titles**: Milestone titles should be concise and descriptive
|
||||||
|
3. **Add context**: Include a brief description explaining what this milestone encompasses
|
||||||
|
4. **Order matters**: List milestones in the order they should be completed
|
||||||
|
5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Respond with ONLY a valid JSON array of milestone suggestions.`;
|
||||||
|
const MAX_GOAL_PROMPT_LENGTH = 4000;
|
||||||
|
export const SUGGESTION_TIMEOUT_MS = 120_000;
|
||||||
|
const DEFAULT_SUGGESTION_COUNT = 5;
|
||||||
|
const MAX_SUGGESTION_COUNT = 10;
|
||||||
|
const MIN_SUGGESTION_COUNT = 1;
|
||||||
|
const MAX_PARSE_RETRIES = 1;
|
||||||
|
export function validateSuggestionInput(input) {
|
||||||
|
if (!input || typeof input !== "object") {
|
||||||
|
throw new ValidationError("Request body must be an object");
|
||||||
|
}
|
||||||
|
const { goalPrompt, count } = input;
|
||||||
|
if (typeof goalPrompt !== "string" || !goalPrompt.trim()) {
|
||||||
|
throw new ValidationError("goalPrompt is required and must be a non-empty string");
|
||||||
|
}
|
||||||
|
if (goalPrompt.length > MAX_GOAL_PROMPT_LENGTH) {
|
||||||
|
throw new ValidationError(`goalPrompt exceeds maximum length of ${MAX_GOAL_PROMPT_LENGTH} characters`);
|
||||||
|
}
|
||||||
|
if (count !== undefined) {
|
||||||
|
if (typeof count !== "number" || !Number.isInteger(count))
|
||||||
|
throw new ValidationError("count must be an integer");
|
||||||
|
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||||
|
throw new ValidationError(`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function extractJsonCandidate(text) {
|
||||||
|
if (!text || !text.trim())
|
||||||
|
return null;
|
||||||
|
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||||||
|
const source = codeBlockMatch?.[1]?.trim() || text.trim();
|
||||||
|
const startIndex = source.indexOf("[");
|
||||||
|
if (startIndex < 0)
|
||||||
|
return null;
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let index = startIndex; index < source.length; index++) {
|
||||||
|
const char = source[index];
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
}
|
||||||
|
else if (char === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
}
|
||||||
|
else if (char === '"') {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"') {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "[")
|
||||||
|
depth++;
|
||||||
|
if (char === "]") {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
return source.slice(startIndex, index + 1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return source.slice(startIndex).trim();
|
||||||
|
}
|
||||||
|
function repairJson(text) {
|
||||||
|
let repaired = text.replace(/,\s*([}\]])/g, "$1");
|
||||||
|
let depthBraces = 0;
|
||||||
|
let depthBrackets = 0;
|
||||||
|
for (const ch of repaired) {
|
||||||
|
if (ch === "{")
|
||||||
|
depthBraces++;
|
||||||
|
if (ch === "}")
|
||||||
|
depthBraces--;
|
||||||
|
if (ch === "[")
|
||||||
|
depthBrackets++;
|
||||||
|
if (ch === "]")
|
||||||
|
depthBrackets--;
|
||||||
|
}
|
||||||
|
repaired += "]".repeat(Math.max(0, depthBrackets));
|
||||||
|
repaired += "}".repeat(Math.max(0, depthBraces));
|
||||||
|
return repaired;
|
||||||
|
}
|
||||||
|
function parseMilestoneSuggestions(text) {
|
||||||
|
const candidate = extractJsonCandidate(text);
|
||||||
|
if (!candidate)
|
||||||
|
throw new ParseError("AI returned no valid JSON. Please try again.");
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(candidate);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
parsed = JSON.parse(repairJson(candidate));
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new ParseError("AI response must be a JSON array of milestone suggestions");
|
||||||
|
}
|
||||||
|
const suggestions = [];
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (!item || typeof item !== "object")
|
||||||
|
continue;
|
||||||
|
const row = item;
|
||||||
|
if (typeof row.title !== "string" || !row.title.trim())
|
||||||
|
continue;
|
||||||
|
suggestions.push({
|
||||||
|
title: row.title.trim(),
|
||||||
|
description: typeof row.description === "string" && row.description.trim() ? row.description.trim() : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (suggestions.length === 0)
|
||||||
|
throw new ParseError("AI returned no valid milestone suggestions");
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
function pickFactory(explicit) {
|
||||||
|
return explicit ?? injectedCreateAiSession;
|
||||||
|
}
|
||||||
|
async function runPrompt(createAiSession, options, prompt) {
|
||||||
|
const agent = await createAiSession(options);
|
||||||
|
await agent.session.prompt(prompt);
|
||||||
|
const lastMessage = agent.session.state.messages
|
||||||
|
.filter((m) => m.role === "assistant")
|
||||||
|
.pop();
|
||||||
|
let text = "";
|
||||||
|
if (lastMessage?.content) {
|
||||||
|
if (typeof lastMessage.content === "string")
|
||||||
|
text = lastMessage.content;
|
||||||
|
else {
|
||||||
|
text = lastMessage.content
|
||||||
|
.filter((c) => c.type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { text, dispose: agent.session.dispose };
|
||||||
|
}
|
||||||
|
export async function generateMilestoneSuggestions(goalPrompt, count = DEFAULT_SUGGESTION_COUNT, rootDir, modelProvider, modelId, createAiSession) {
|
||||||
|
const factory = pickFactory(createAiSession);
|
||||||
|
if (!factory)
|
||||||
|
throw new ServiceUnavailableError("AI service is not available");
|
||||||
|
if (!rootDir)
|
||||||
|
throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||||
|
const result = await Promise.race([
|
||||||
|
(async () => {
|
||||||
|
let dispose;
|
||||||
|
try {
|
||||||
|
let response = await runPrompt(factory, {
|
||||||
|
cwd: rootDir,
|
||||||
|
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||||
|
tools: "readonly",
|
||||||
|
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||||
|
}, `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`);
|
||||||
|
dispose = response.dispose;
|
||||||
|
let suggestions;
|
||||||
|
let lastError;
|
||||||
|
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
suggestions = parseMilestoneSuggestions(response.text);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
if (attempt === MAX_PARSE_RETRIES)
|
||||||
|
break;
|
||||||
|
response = await runPrompt(factory, {
|
||||||
|
cwd: rootDir,
|
||||||
|
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||||
|
tools: "readonly",
|
||||||
|
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||||
|
}, "Your previous response could not be parsed as JSON. Respond with only a JSON array.");
|
||||||
|
dispose = response.dispose;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!suggestions) {
|
||||||
|
throw new ParseError(`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message ?? "Unknown error"}`);
|
||||||
|
}
|
||||||
|
return suggestions.slice(0, count);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
dispose?.();
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
|
||||||
|
]);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.`;
|
||||||
|
const MAX_FEATURE_PROMPT_LENGTH = 2000;
|
||||||
|
export function validateFeatureSuggestionInput(input) {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input))
|
||||||
|
throw new ValidationError("Request body must be an object");
|
||||||
|
const { prompt, count } = input;
|
||||||
|
if (prompt !== undefined) {
|
||||||
|
if (typeof prompt !== "string")
|
||||||
|
throw new ValidationError("prompt must be a string");
|
||||||
|
if (prompt.length > MAX_FEATURE_PROMPT_LENGTH)
|
||||||
|
throw new ValidationError(`prompt exceeds maximum length of ${MAX_FEATURE_PROMPT_LENGTH} characters`);
|
||||||
|
}
|
||||||
|
if (count !== undefined) {
|
||||||
|
if (typeof count !== "number" || !Number.isInteger(count))
|
||||||
|
throw new ValidationError("count must be an integer");
|
||||||
|
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||||
|
throw new ValidationError(`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function buildMilestoneContextString(context) {
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`Roadmap: ${context.roadmapTitle}`);
|
||||||
|
if (context.roadmapDescription)
|
||||||
|
lines.push(`Description: ${context.roadmapDescription}`);
|
||||||
|
lines.push("", `Milestone: ${context.milestoneTitle}`);
|
||||||
|
if (context.milestoneDescription)
|
||||||
|
lines.push(`Description: ${context.milestoneDescription}`);
|
||||||
|
if (context.existingFeatureTitles.length > 0) {
|
||||||
|
lines.push("", "Existing features in this milestone:");
|
||||||
|
for (const title of context.existingFeatureTitles)
|
||||||
|
lines.push(` - ${title}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
function parseFeatureSuggestions(text) {
|
||||||
|
return parseMilestoneSuggestions(text);
|
||||||
|
}
|
||||||
|
export async function generateFeatureSuggestions(context, count = DEFAULT_SUGGESTION_COUNT, prompt, rootDir, modelProvider, modelId, createAiSession) {
|
||||||
|
const factory = pickFactory(createAiSession);
|
||||||
|
if (!factory)
|
||||||
|
throw new ServiceUnavailableError("AI service is not available");
|
||||||
|
if (!rootDir)
|
||||||
|
throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||||
|
const systemPrompt = `${FEATURE_SUGGESTION_SYSTEM_PROMPT}\n\n${buildMilestoneContextString(context)}`;
|
||||||
|
const userMessage = prompt?.trim()
|
||||||
|
? `Please suggest ${count} features for the milestone described above.\n\nAdditional guidance:\n${prompt.trim()}`
|
||||||
|
: `Please suggest ${count} features for the milestone described above.`;
|
||||||
|
const result = await Promise.race([
|
||||||
|
(async () => {
|
||||||
|
const { text, dispose } = await runPrompt(factory, {
|
||||||
|
cwd: rootDir,
|
||||||
|
systemPrompt,
|
||||||
|
tools: "readonly",
|
||||||
|
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||||
|
}, userMessage);
|
||||||
|
try {
|
||||||
|
return parseFeatureSuggestions(text).slice(0, count);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
dispose?.();
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
|
||||||
|
]);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
export class ValidationError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ValidationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ParseError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ParseError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ServiceUnavailableError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ServiceUnavailableError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function __resetSuggestionState() {
|
||||||
|
injectedCreateAiSession = undefined;
|
||||||
|
}
|
||||||
|
export function __setCreateFnAgent(mock) {
|
||||||
|
if (!mock) {
|
||||||
|
injectedCreateAiSession = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
injectedCreateAiSession = async (options) => mock(options);
|
||||||
|
}
|
||||||
|
export function __setCreateAiSessionFactory(mock) {
|
||||||
|
injectedCreateAiSession = mock;
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=roadmap-suggestions.js.map
|
||||||
File diff suppressed because one or more lines are too long
381
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts
Normal file
381
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import type { CreateAiSessionFactory } from "@fusion/core";
|
||||||
|
|
||||||
|
type LegacyCreateFnAgent = (options: {
|
||||||
|
cwd: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
tools?: "coding" | "readonly";
|
||||||
|
defaultProvider?: string;
|
||||||
|
defaultModelId?: string;
|
||||||
|
onThinking?: () => void;
|
||||||
|
onText?: () => void;
|
||||||
|
}) => Promise<{
|
||||||
|
session: {
|
||||||
|
prompt(text: string): Promise<void>;
|
||||||
|
state: { messages: Array<{ role: string; content?: string | Array<{ type: string; text: string }> }> };
|
||||||
|
dispose?: () => void;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
let injectedCreateAiSession: CreateAiSessionFactory | undefined;
|
||||||
|
|
||||||
|
export interface GenerateMilestoneSuggestionsInput {
|
||||||
|
goalPrompt: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MilestoneSuggestion {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MILESTONE_SUGGESTION_SYSTEM_PROMPT = `You are a milestone planning assistant for a product roadmap system.
|
||||||
|
|
||||||
|
Your job is to suggest logical milestones that would help achieve a user's roadmap goal.
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
1. **Think about phases**: Break the goal into logical phases
|
||||||
|
2. **Use clear titles**: Milestone titles should be concise and descriptive
|
||||||
|
3. **Add context**: Include a brief description explaining what this milestone encompasses
|
||||||
|
4. **Order matters**: List milestones in the order they should be completed
|
||||||
|
5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Respond with ONLY a valid JSON array of milestone suggestions.`;
|
||||||
|
|
||||||
|
const MAX_GOAL_PROMPT_LENGTH = 4000;
|
||||||
|
export const SUGGESTION_TIMEOUT_MS = 120_000;
|
||||||
|
const DEFAULT_SUGGESTION_COUNT = 5;
|
||||||
|
const MAX_SUGGESTION_COUNT = 10;
|
||||||
|
const MIN_SUGGESTION_COUNT = 1;
|
||||||
|
const MAX_PARSE_RETRIES = 1;
|
||||||
|
|
||||||
|
export function validateSuggestionInput(input: unknown): asserts input is GenerateMilestoneSuggestionsInput {
|
||||||
|
if (!input || typeof input !== "object") {
|
||||||
|
throw new ValidationError("Request body must be an object");
|
||||||
|
}
|
||||||
|
const { goalPrompt, count } = input as Record<string, unknown>;
|
||||||
|
if (typeof goalPrompt !== "string" || !goalPrompt.trim()) {
|
||||||
|
throw new ValidationError("goalPrompt is required and must be a non-empty string");
|
||||||
|
}
|
||||||
|
if (goalPrompt.length > MAX_GOAL_PROMPT_LENGTH) {
|
||||||
|
throw new ValidationError(`goalPrompt exceeds maximum length of ${MAX_GOAL_PROMPT_LENGTH} characters`);
|
||||||
|
}
|
||||||
|
if (count !== undefined) {
|
||||||
|
if (typeof count !== "number" || !Number.isInteger(count)) throw new ValidationError("count must be an integer");
|
||||||
|
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||||
|
throw new ValidationError(`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonCandidate(text: string): string | null {
|
||||||
|
if (!text || !text.trim()) return null;
|
||||||
|
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||||||
|
const source = codeBlockMatch?.[1]?.trim() || text.trim();
|
||||||
|
|
||||||
|
const startIndex = source.indexOf("[");
|
||||||
|
if (startIndex < 0) return null;
|
||||||
|
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let index = startIndex; index < source.length; index++) {
|
||||||
|
const char = source[index];
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (char === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
} else if (char === '"') {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "[") depth++;
|
||||||
|
if (char === "]") {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
return source.slice(startIndex, index + 1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.slice(startIndex).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairJson(text: string): string {
|
||||||
|
let repaired = text.replace(/,\s*([}\]])/g, "$1");
|
||||||
|
let depthBraces = 0;
|
||||||
|
let depthBrackets = 0;
|
||||||
|
for (const ch of repaired) {
|
||||||
|
if (ch === "{") depthBraces++;
|
||||||
|
if (ch === "}") depthBraces--;
|
||||||
|
if (ch === "[") depthBrackets++;
|
||||||
|
if (ch === "]") depthBrackets--;
|
||||||
|
}
|
||||||
|
repaired += "]".repeat(Math.max(0, depthBrackets));
|
||||||
|
repaired += "}".repeat(Math.max(0, depthBraces));
|
||||||
|
return repaired;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMilestoneSuggestions(text: string): MilestoneSuggestion[] {
|
||||||
|
const candidate = extractJsonCandidate(text);
|
||||||
|
if (!candidate) throw new ParseError("AI returned no valid JSON. Please try again.");
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(candidate);
|
||||||
|
} catch {
|
||||||
|
parsed = JSON.parse(repairJson(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new ParseError("AI response must be a JSON array of milestone suggestions");
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions: MilestoneSuggestion[] = [];
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
if (typeof row.title !== "string" || !row.title.trim()) continue;
|
||||||
|
suggestions.push({
|
||||||
|
title: row.title.trim(),
|
||||||
|
description: typeof row.description === "string" && row.description.trim() ? row.description.trim() : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestions.length === 0) throw new ParseError("AI returned no valid milestone suggestions");
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentMessage {
|
||||||
|
role: string;
|
||||||
|
content?: string | Array<{ type: string; text: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFactory(explicit?: CreateAiSessionFactory): CreateAiSessionFactory | undefined {
|
||||||
|
return explicit ?? injectedCreateAiSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPrompt(
|
||||||
|
createAiSession: CreateAiSessionFactory,
|
||||||
|
options: Parameters<CreateAiSessionFactory>[0],
|
||||||
|
prompt: string,
|
||||||
|
): Promise<{ text: string; dispose?: () => void }> {
|
||||||
|
const agent = await createAiSession(options);
|
||||||
|
await agent.session.prompt(prompt);
|
||||||
|
const lastMessage = (agent.session.state.messages as AgentMessage[])
|
||||||
|
.filter((m) => m.role === "assistant")
|
||||||
|
.pop();
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
if (lastMessage?.content) {
|
||||||
|
if (typeof lastMessage.content === "string") text = lastMessage.content;
|
||||||
|
else {
|
||||||
|
text = lastMessage.content
|
||||||
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text, dispose: (agent.session as { dispose?: () => void }).dispose };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMilestoneSuggestions(
|
||||||
|
goalPrompt: string,
|
||||||
|
count: number = DEFAULT_SUGGESTION_COUNT,
|
||||||
|
rootDir?: string,
|
||||||
|
modelProvider?: string,
|
||||||
|
modelId?: string,
|
||||||
|
createAiSession?: CreateAiSessionFactory,
|
||||||
|
): Promise<MilestoneSuggestion[]> {
|
||||||
|
const factory = pickFactory(createAiSession);
|
||||||
|
if (!factory) throw new ServiceUnavailableError("AI service is not available");
|
||||||
|
if (!rootDir) throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
(async () => {
|
||||||
|
let dispose: (() => void) | undefined;
|
||||||
|
try {
|
||||||
|
let response = await runPrompt(factory, {
|
||||||
|
cwd: rootDir,
|
||||||
|
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||||
|
tools: "readonly",
|
||||||
|
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||||
|
}, `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`);
|
||||||
|
dispose = response.dispose;
|
||||||
|
|
||||||
|
let suggestions: MilestoneSuggestion[] | undefined;
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
suggestions = parseMilestoneSuggestions(response.text);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
if (attempt === MAX_PARSE_RETRIES) break;
|
||||||
|
response = await runPrompt(factory, {
|
||||||
|
cwd: rootDir,
|
||||||
|
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||||
|
tools: "readonly",
|
||||||
|
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||||
|
}, "Your previous response could not be parsed as JSON. Respond with only a JSON array.");
|
||||||
|
dispose = response.dispose;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!suggestions) {
|
||||||
|
throw new ParseError(`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message ?? "Unknown error"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions.slice(0, count);
|
||||||
|
} finally {
|
||||||
|
dispose?.();
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateFeatureSuggestionsInput {
|
||||||
|
prompt?: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureSuggestion {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureSuggestionContext {
|
||||||
|
roadmapTitle: string;
|
||||||
|
roadmapDescription?: string;
|
||||||
|
milestoneTitle: string;
|
||||||
|
milestoneDescription?: string;
|
||||||
|
existingFeatureTitles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.`;
|
||||||
|
|
||||||
|
const MAX_FEATURE_PROMPT_LENGTH = 2000;
|
||||||
|
|
||||||
|
export function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) throw new ValidationError("Request body must be an object");
|
||||||
|
const { prompt, count } = input as Record<string, unknown>;
|
||||||
|
if (prompt !== undefined) {
|
||||||
|
if (typeof prompt !== "string") throw new ValidationError("prompt must be a string");
|
||||||
|
if (prompt.length > MAX_FEATURE_PROMPT_LENGTH) throw new ValidationError(`prompt exceeds maximum length of ${MAX_FEATURE_PROMPT_LENGTH} characters`);
|
||||||
|
}
|
||||||
|
if (count !== undefined) {
|
||||||
|
if (typeof count !== "number" || !Number.isInteger(count)) throw new ValidationError("count must be an integer");
|
||||||
|
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||||
|
throw new ValidationError(`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMilestoneContextString(context: FeatureSuggestionContext): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`Roadmap: ${context.roadmapTitle}`);
|
||||||
|
if (context.roadmapDescription) lines.push(`Description: ${context.roadmapDescription}`);
|
||||||
|
lines.push("", `Milestone: ${context.milestoneTitle}`);
|
||||||
|
if (context.milestoneDescription) lines.push(`Description: ${context.milestoneDescription}`);
|
||||||
|
if (context.existingFeatureTitles.length > 0) {
|
||||||
|
lines.push("", "Existing features in this milestone:");
|
||||||
|
for (const title of context.existingFeatureTitles) lines.push(` - ${title}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFeatureSuggestions(text: string): FeatureSuggestion[] {
|
||||||
|
return parseMilestoneSuggestions(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateFeatureSuggestions(
|
||||||
|
context: FeatureSuggestionContext,
|
||||||
|
count: number = DEFAULT_SUGGESTION_COUNT,
|
||||||
|
prompt?: string,
|
||||||
|
rootDir?: string,
|
||||||
|
modelProvider?: string,
|
||||||
|
modelId?: string,
|
||||||
|
createAiSession?: CreateAiSessionFactory,
|
||||||
|
): Promise<FeatureSuggestion[]> {
|
||||||
|
const factory = pickFactory(createAiSession);
|
||||||
|
if (!factory) throw new ServiceUnavailableError("AI service is not available");
|
||||||
|
if (!rootDir) throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||||
|
|
||||||
|
const systemPrompt = `${FEATURE_SUGGESTION_SYSTEM_PROMPT}\n\n${buildMilestoneContextString(context)}`;
|
||||||
|
const userMessage = prompt?.trim()
|
||||||
|
? `Please suggest ${count} features for the milestone described above.\n\nAdditional guidance:\n${prompt.trim()}`
|
||||||
|
: `Please suggest ${count} features for the milestone described above.`;
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
(async () => {
|
||||||
|
const { text, dispose } = await runPrompt(factory, {
|
||||||
|
cwd: rootDir,
|
||||||
|
systemPrompt,
|
||||||
|
tools: "readonly",
|
||||||
|
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||||
|
}, userMessage);
|
||||||
|
try {
|
||||||
|
return parseFeatureSuggestions(text).slice(0, count);
|
||||||
|
} finally {
|
||||||
|
dispose?.();
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ValidationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ParseError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ParseError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServiceUnavailableError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ServiceUnavailableError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __resetSuggestionState(): void {
|
||||||
|
injectedCreateAiSession = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __setCreateFnAgent(mock: LegacyCreateFnAgent | undefined): void {
|
||||||
|
if (!mock) {
|
||||||
|
injectedCreateAiSession = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
injectedCreateAiSession = async (options) => mock(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __setCreateAiSessionFactory(mock: CreateAiSessionFactory | undefined): void {
|
||||||
|
injectedCreateAiSession = mock;
|
||||||
|
}
|
||||||
1
plugins/fusion-plugin-roadmap/src/server/index.d.ts
vendored
Normal file
1
plugins/fusion-plugin-roadmap/src/server/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { createRoadmapPluginRoutes } from "../routes/roadmap-routes.js";
|
||||||
@@ -1 +1 @@
|
|||||||
export { createRoadmapPluginRoutes } from "../roadmap-routes.js";
|
export { createRoadmapPluginRoutes } from "../routes/roadmap-routes.js";
|
||||||
|
|||||||
37
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts
vendored
Normal file
37
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { RoadmapFeature, RoadmapFeatureMoveInput, RoadmapFeatureMoveResult, RoadmapFeatureReorderInput, RoadmapMilestone, RoadmapMilestoneReorderInput } from "../roadmap-types.js";
|
||||||
|
/**
|
||||||
|
* Repairs milestone ordering for a single roadmap scope.
|
||||||
|
*
|
||||||
|
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||||
|
*/
|
||||||
|
export declare function normalizeRoadmapMilestoneOrder(milestones: readonly RoadmapMilestone[]): RoadmapMilestone[];
|
||||||
|
/**
|
||||||
|
* Applies an explicit milestone reorder for a single roadmap scope.
|
||||||
|
*
|
||||||
|
* The caller must provide the complete milestone ID set exactly once. Partial
|
||||||
|
* or duplicate lists are rejected to keep reorders deterministic.
|
||||||
|
*/
|
||||||
|
export declare function applyRoadmapMilestoneReorder(milestones: readonly RoadmapMilestone[], input: RoadmapMilestoneReorderInput): RoadmapMilestone[];
|
||||||
|
/**
|
||||||
|
* Repairs feature ordering for a single milestone scope.
|
||||||
|
*
|
||||||
|
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||||
|
*/
|
||||||
|
export declare function normalizeRoadmapFeatureOrder(features: readonly RoadmapFeature[]): RoadmapFeature[];
|
||||||
|
/**
|
||||||
|
* Applies an explicit feature reorder for a single milestone scope.
|
||||||
|
*
|
||||||
|
* The caller must provide the complete feature ID set exactly once. Partial or
|
||||||
|
* duplicate lists are rejected to keep reorder behavior deterministic.
|
||||||
|
*/
|
||||||
|
export declare function applyRoadmapFeatureReorder(features: readonly RoadmapFeature[], input: RoadmapFeatureReorderInput): RoadmapFeature[];
|
||||||
|
/**
|
||||||
|
* Moves a feature within the affected milestone scope and deterministically
|
||||||
|
* normalizes both source and destination order.
|
||||||
|
*
|
||||||
|
* For cross-milestone moves, pass the combined feature list from the source and
|
||||||
|
* target milestones. For within-milestone moves, pass the current milestone's
|
||||||
|
* feature list. `targetOrderIndex` is clamped into the destination range.
|
||||||
|
*/
|
||||||
|
export declare function moveRoadmapFeature(features: readonly RoadmapFeature[], input: RoadmapFeatureMoveInput): RoadmapFeatureMoveResult;
|
||||||
|
//# sourceMappingURL=roadmap-ordering.d.ts.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"roadmap-ordering.d.ts","sourceRoot":"","sources":["roadmap-ordering.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,uBAAuB,EACvB,wBAAwB,EACxB,0BAA0B,EAC1B,gBAAgB,EAChB,4BAA4B,EAC7B,MAAM,qBAAqB,CAAC;AA8I7B;;;;GAIG;AACH,wBAAgB,8BAA8B,CAC5C,UAAU,EAAE,SAAS,gBAAgB,EAAE,GACtC,gBAAgB,EAAE,CAUpB;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,UAAU,EAAE,SAAS,gBAAgB,EAAE,EACvC,KAAK,EAAE,4BAA4B,GAClC,gBAAgB,EAAE,CAWpB;AAED;;;;GAIG;AACH,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,SAAS,cAAc,EAAE,GAClC,cAAc,EAAE,CAUlB;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,SAAS,cAAc,EAAE,EACnC,KAAK,EAAE,0BAA0B,GAChC,cAAc,EAAE,CAWlB;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,SAAS,cAAc,EAAE,EACnC,KAAK,EAAE,uBAAuB,GAC7B,wBAAwB,CAoE1B"}
|
||||||
188
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js
Normal file
188
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
function compareOrderedEntities(a, b) {
|
||||||
|
if (a.orderIndex !== b.orderIndex) {
|
||||||
|
return a.orderIndex - b.orderIndex;
|
||||||
|
}
|
||||||
|
if (a.createdAt !== b.createdAt) {
|
||||||
|
return a.createdAt.localeCompare(b.createdAt);
|
||||||
|
}
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
function clampInsertionIndex(targetIndex, length) {
|
||||||
|
if (!Number.isFinite(targetIndex)) {
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
const normalized = Math.trunc(targetIndex);
|
||||||
|
if (normalized < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (normalized > length) {
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
function assertScopedRoadmapMilestones(milestones, roadmapId) {
|
||||||
|
for (const milestone of milestones) {
|
||||||
|
if (milestone.roadmapId !== roadmapId) {
|
||||||
|
throw new Error(`Milestone ${milestone.id} does not belong to roadmap ${roadmapId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function assertScopedMilestoneFeatures(features, milestoneId) {
|
||||||
|
for (const feature of features) {
|
||||||
|
if (feature.milestoneId !== milestoneId) {
|
||||||
|
throw new Error(`Feature ${feature.id} does not belong to milestone ${milestoneId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function assertScopedMoveFeatures(features, fromMilestoneId, toMilestoneId) {
|
||||||
|
const validMilestoneIds = new Set([fromMilestoneId, toMilestoneId]);
|
||||||
|
for (const feature of features) {
|
||||||
|
if (!validMilestoneIds.has(feature.milestoneId)) {
|
||||||
|
throw new Error(`Feature ${feature.id} is outside the affected milestone scope (${fromMilestoneId} → ${toMilestoneId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function assertExactIdSet(entityLabel, actualIds, orderedIds) {
|
||||||
|
const requestedIds = new Set();
|
||||||
|
for (const id of orderedIds) {
|
||||||
|
if (requestedIds.has(id)) {
|
||||||
|
throw new Error(`Duplicate ${entityLabel} id in requested order: ${id}`);
|
||||||
|
}
|
||||||
|
requestedIds.add(id);
|
||||||
|
}
|
||||||
|
if (actualIds.length !== orderedIds.length) {
|
||||||
|
throw new Error(`Expected ${actualIds.length} ${entityLabel} ids but received ${orderedIds.length}`);
|
||||||
|
}
|
||||||
|
const actualIdSet = new Set(actualIds);
|
||||||
|
for (const id of orderedIds) {
|
||||||
|
if (!actualIdSet.has(id)) {
|
||||||
|
throw new Error(`${capitalize(entityLabel)} ${id} not found in scoped list`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of actualIds) {
|
||||||
|
if (!requestedIds.has(id)) {
|
||||||
|
throw new Error(`Missing ${entityLabel} id in requested order: ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function capitalize(value) {
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
}
|
||||||
|
function assignContiguousOrder(items) {
|
||||||
|
return items.map((item, orderIndex) => {
|
||||||
|
if (item.orderIndex === orderIndex) {
|
||||||
|
return { ...item };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
orderIndex,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Repairs milestone ordering for a single roadmap scope.
|
||||||
|
*
|
||||||
|
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||||
|
*/
|
||||||
|
export function normalizeRoadmapMilestoneOrder(milestones) {
|
||||||
|
if (milestones.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
assertScopedRoadmapMilestones(milestones, milestones[0].roadmapId);
|
||||||
|
return assignContiguousOrder([...milestones].sort(compareOrderedEntities));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Applies an explicit milestone reorder for a single roadmap scope.
|
||||||
|
*
|
||||||
|
* The caller must provide the complete milestone ID set exactly once. Partial
|
||||||
|
* or duplicate lists are rejected to keep reorders deterministic.
|
||||||
|
*/
|
||||||
|
export function applyRoadmapMilestoneReorder(milestones, input) {
|
||||||
|
assertScopedRoadmapMilestones(milestones, input.roadmapId);
|
||||||
|
const normalized = normalizeRoadmapMilestoneOrder(milestones);
|
||||||
|
const ids = normalized.map((milestone) => milestone.id);
|
||||||
|
assertExactIdSet("milestone", ids, input.orderedMilestoneIds);
|
||||||
|
const byId = new Map(normalized.map((milestone) => [milestone.id, milestone]));
|
||||||
|
return assignContiguousOrder(input.orderedMilestoneIds.map((id) => byId.get(id)));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Repairs feature ordering for a single milestone scope.
|
||||||
|
*
|
||||||
|
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||||
|
*/
|
||||||
|
export function normalizeRoadmapFeatureOrder(features) {
|
||||||
|
if (features.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
assertScopedMilestoneFeatures(features, features[0].milestoneId);
|
||||||
|
return assignContiguousOrder([...features].sort(compareOrderedEntities));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Applies an explicit feature reorder for a single milestone scope.
|
||||||
|
*
|
||||||
|
* The caller must provide the complete feature ID set exactly once. Partial or
|
||||||
|
* duplicate lists are rejected to keep reorder behavior deterministic.
|
||||||
|
*/
|
||||||
|
export function applyRoadmapFeatureReorder(features, input) {
|
||||||
|
assertScopedMilestoneFeatures(features, input.milestoneId);
|
||||||
|
const normalized = normalizeRoadmapFeatureOrder(features);
|
||||||
|
const ids = normalized.map((feature) => feature.id);
|
||||||
|
assertExactIdSet("feature", ids, input.orderedFeatureIds);
|
||||||
|
const byId = new Map(normalized.map((feature) => [feature.id, feature]));
|
||||||
|
return assignContiguousOrder(input.orderedFeatureIds.map((id) => byId.get(id)));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Moves a feature within the affected milestone scope and deterministically
|
||||||
|
* normalizes both source and destination order.
|
||||||
|
*
|
||||||
|
* For cross-milestone moves, pass the combined feature list from the source and
|
||||||
|
* target milestones. For within-milestone moves, pass the current milestone's
|
||||||
|
* feature list. `targetOrderIndex` is clamped into the destination range.
|
||||||
|
*/
|
||||||
|
export function moveRoadmapFeature(features, input) {
|
||||||
|
assertScopedMoveFeatures(features, input.fromMilestoneId, input.toMilestoneId);
|
||||||
|
const existingFeature = features.find((feature) => feature.id === input.featureId);
|
||||||
|
if (!existingFeature) {
|
||||||
|
throw new Error(`Feature ${input.featureId} not found in affected milestone scope`);
|
||||||
|
}
|
||||||
|
if (existingFeature.milestoneId !== input.fromMilestoneId) {
|
||||||
|
throw new Error(`Feature ${input.featureId} does not belong to milestone ${input.fromMilestoneId}`);
|
||||||
|
}
|
||||||
|
const sourceFeatures = normalizeRoadmapFeatureOrder(features.filter((feature) => feature.milestoneId === input.fromMilestoneId));
|
||||||
|
const sourceWithoutFeature = sourceFeatures.filter((feature) => feature.id !== input.featureId);
|
||||||
|
if (input.fromMilestoneId === input.toMilestoneId) {
|
||||||
|
const insertionIndex = clampInsertionIndex(input.targetOrderIndex, sourceWithoutFeature.length);
|
||||||
|
const reordered = [...sourceWithoutFeature];
|
||||||
|
reordered.splice(insertionIndex, 0, {
|
||||||
|
...existingFeature,
|
||||||
|
milestoneId: input.toMilestoneId,
|
||||||
|
orderIndex: insertionIndex,
|
||||||
|
});
|
||||||
|
const normalized = assignContiguousOrder(reordered);
|
||||||
|
const movedFeature = normalized.find((feature) => feature.id === input.featureId);
|
||||||
|
return {
|
||||||
|
movedFeature,
|
||||||
|
affectedFeatures: normalized,
|
||||||
|
sourceMilestoneFeatures: normalized,
|
||||||
|
targetMilestoneFeatures: normalized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const targetFeatures = normalizeRoadmapFeatureOrder(features.filter((feature) => feature.milestoneId === input.toMilestoneId));
|
||||||
|
const insertionIndex = clampInsertionIndex(input.targetOrderIndex, targetFeatures.length);
|
||||||
|
const targetWithInsertedFeature = [...targetFeatures];
|
||||||
|
targetWithInsertedFeature.splice(insertionIndex, 0, {
|
||||||
|
...existingFeature,
|
||||||
|
milestoneId: input.toMilestoneId,
|
||||||
|
orderIndex: insertionIndex,
|
||||||
|
});
|
||||||
|
const normalizedSource = assignContiguousOrder(sourceWithoutFeature);
|
||||||
|
const normalizedTarget = assignContiguousOrder(targetWithInsertedFeature);
|
||||||
|
const movedFeature = normalizedTarget.find((feature) => feature.id === input.featureId);
|
||||||
|
return {
|
||||||
|
movedFeature,
|
||||||
|
affectedFeatures: [...normalizedSource, ...normalizedTarget],
|
||||||
|
sourceMilestoneFeatures: normalizedSource,
|
||||||
|
targetMilestoneFeatures: normalizedTarget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=roadmap-ordering.js.map
|
||||||
File diff suppressed because one or more lines are too long
299
plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts
vendored
Normal file
299
plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts
vendored
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* RoadmapStore - Data layer for standalone roadmap persistence.
|
||||||
|
*
|
||||||
|
* Manages CRUD operations for roadmaps, milestones, and features.
|
||||||
|
* Provides deterministic ordering via covering indexes and atomic reorder/move operations.
|
||||||
|
*
|
||||||
|
* Ordering invariants:
|
||||||
|
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
|
||||||
|
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
|
||||||
|
* - all list/read queries use deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||||
|
* - cross-milestone feature moves atomically renumber both affected milestone scopes
|
||||||
|
*/
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import type { Database } from "@fusion/core";
|
||||||
|
import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapCreateInput, RoadmapUpdateInput, RoadmapMilestoneCreateInput, RoadmapMilestoneUpdateInput, RoadmapFeatureCreateInput, RoadmapFeatureUpdateInput, RoadmapMilestoneReorderInput, RoadmapFeatureReorderInput, RoadmapFeatureMoveInput, RoadmapMilestoneWithFeatures, RoadmapWithHierarchy, RoadmapExportBundle, RoadmapMissionPlanningHandoff, RoadmapFeatureTaskPlanningHandoff } from "../roadmap-types.js";
|
||||||
|
export interface RoadmapStoreEvents {
|
||||||
|
/** Emitted when a roadmap is created */
|
||||||
|
"roadmap:created": [Roadmap];
|
||||||
|
/** Emitted when a roadmap is updated */
|
||||||
|
"roadmap:updated": [Roadmap];
|
||||||
|
/** Emitted when a roadmap is deleted */
|
||||||
|
"roadmap:deleted": [string];
|
||||||
|
/** Emitted when a milestone is created */
|
||||||
|
"milestone:created": [RoadmapMilestone];
|
||||||
|
/** Emitted when a milestone is updated */
|
||||||
|
"milestone:updated": [RoadmapMilestone];
|
||||||
|
/** Emitted when a milestone is deleted */
|
||||||
|
"milestone:deleted": [string];
|
||||||
|
/** Emitted when a milestone is reordered */
|
||||||
|
"milestone:reordered": [{
|
||||||
|
roadmapId: string;
|
||||||
|
milestones: RoadmapMilestone[];
|
||||||
|
}];
|
||||||
|
/** Emitted when a feature is created */
|
||||||
|
"feature:created": [RoadmapFeature];
|
||||||
|
/** Emitted when a feature is updated */
|
||||||
|
"feature:updated": [RoadmapFeature];
|
||||||
|
/** Emitted when a feature is deleted */
|
||||||
|
"feature:deleted": [RoadmapFeature];
|
||||||
|
/** Emitted when features are reordered within a milestone */
|
||||||
|
"feature:reordered": [{
|
||||||
|
milestoneId: string;
|
||||||
|
features: RoadmapFeature[];
|
||||||
|
}];
|
||||||
|
/** Emitted when a feature is moved (including cross-milestone moves) */
|
||||||
|
"feature:moved": [{
|
||||||
|
feature: RoadmapFeature;
|
||||||
|
fromMilestoneId: string;
|
||||||
|
toMilestoneId: string;
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
export declare class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
|
||||||
|
private db;
|
||||||
|
/**
|
||||||
|
* Creates a new RoadmapStore instance.
|
||||||
|
*
|
||||||
|
* @param db - Shared Database instance (same instance used by TaskStore)
|
||||||
|
*/
|
||||||
|
constructor(db: Database);
|
||||||
|
private ensureSchema;
|
||||||
|
private generateRoadmapId;
|
||||||
|
private generateMilestoneId;
|
||||||
|
private generateFeatureId;
|
||||||
|
private rowToRoadmap;
|
||||||
|
private rowToMilestone;
|
||||||
|
private rowToFeature;
|
||||||
|
/**
|
||||||
|
* Create a new roadmap.
|
||||||
|
*
|
||||||
|
* @param input - Roadmap creation input
|
||||||
|
* @returns The created roadmap
|
||||||
|
*/
|
||||||
|
createRoadmap(input: RoadmapCreateInput): Roadmap;
|
||||||
|
/**
|
||||||
|
* Get a roadmap by ID.
|
||||||
|
*
|
||||||
|
* @param id - Roadmap ID
|
||||||
|
* @returns The roadmap, or undefined if not found
|
||||||
|
*/
|
||||||
|
getRoadmap(id: string): Roadmap | undefined;
|
||||||
|
/**
|
||||||
|
* List all roadmaps, ordered by creation date (newest first).
|
||||||
|
*
|
||||||
|
* @returns Array of roadmaps
|
||||||
|
*/
|
||||||
|
listRoadmaps(): Roadmap[];
|
||||||
|
/**
|
||||||
|
* Update a roadmap.
|
||||||
|
*
|
||||||
|
* @param id - Roadmap ID
|
||||||
|
* @param updates - Partial roadmap updates
|
||||||
|
* @returns The updated roadmap
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
updateRoadmap(id: string, updates: RoadmapUpdateInput): Roadmap;
|
||||||
|
/**
|
||||||
|
* Delete a roadmap and all its milestones/features (cascading).
|
||||||
|
*
|
||||||
|
* @param id - Roadmap ID
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
deleteRoadmap(id: string): void;
|
||||||
|
/**
|
||||||
|
* Add a milestone to a roadmap.
|
||||||
|
* Automatically computes the orderIndex (max + 1).
|
||||||
|
*
|
||||||
|
* @param roadmapId - Parent roadmap ID
|
||||||
|
* @param input - Milestone creation input
|
||||||
|
* @returns The created milestone
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
createMilestone(roadmapId: string, input: RoadmapMilestoneCreateInput): RoadmapMilestone;
|
||||||
|
/**
|
||||||
|
* Get a milestone by ID.
|
||||||
|
*
|
||||||
|
* @param id - Milestone ID
|
||||||
|
* @returns The milestone, or undefined if not found
|
||||||
|
*/
|
||||||
|
getMilestone(id: string): RoadmapMilestone | undefined;
|
||||||
|
/**
|
||||||
|
* List milestones for a roadmap, ordered deterministically.
|
||||||
|
*
|
||||||
|
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||||
|
* to ensure consistent results when stored order data is incomplete or conflicting.
|
||||||
|
*
|
||||||
|
* @param roadmapId - Roadmap ID
|
||||||
|
* @returns Array of milestones in deterministic order
|
||||||
|
*/
|
||||||
|
listMilestones(roadmapId: string): RoadmapMilestone[];
|
||||||
|
/**
|
||||||
|
* Update a milestone.
|
||||||
|
*
|
||||||
|
* @param id - Milestone ID
|
||||||
|
* @param updates - Partial milestone updates
|
||||||
|
* @returns The updated milestone
|
||||||
|
* @throws Error if milestone not found
|
||||||
|
*/
|
||||||
|
updateMilestone(id: string, updates: RoadmapMilestoneUpdateInput): RoadmapMilestone;
|
||||||
|
/**
|
||||||
|
* Delete a milestone and all its features (cascading).
|
||||||
|
*
|
||||||
|
* @param id - Milestone ID
|
||||||
|
* @throws Error if milestone not found
|
||||||
|
*/
|
||||||
|
deleteMilestone(id: string): void;
|
||||||
|
/**
|
||||||
|
* Add a feature to a milestone.
|
||||||
|
* Automatically computes the orderIndex (max + 1).
|
||||||
|
*
|
||||||
|
* @param milestoneId - Parent milestone ID
|
||||||
|
* @param input - Feature creation input
|
||||||
|
* @returns The created feature
|
||||||
|
* @throws Error if milestone not found
|
||||||
|
*/
|
||||||
|
createFeature(milestoneId: string, input: RoadmapFeatureCreateInput): RoadmapFeature;
|
||||||
|
/**
|
||||||
|
* Get a feature by ID.
|
||||||
|
*
|
||||||
|
* @param id - Feature ID
|
||||||
|
* @returns The feature, or undefined if not found
|
||||||
|
*/
|
||||||
|
getFeature(id: string): RoadmapFeature | undefined;
|
||||||
|
/**
|
||||||
|
* List features for a milestone, ordered deterministically.
|
||||||
|
*
|
||||||
|
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||||
|
* to ensure consistent results when stored order data is incomplete or conflicting.
|
||||||
|
*
|
||||||
|
* @param milestoneId - Milestone ID
|
||||||
|
* @returns Array of features in deterministic order
|
||||||
|
*/
|
||||||
|
listFeatures(milestoneId: string): RoadmapFeature[];
|
||||||
|
/**
|
||||||
|
* Update a feature.
|
||||||
|
*
|
||||||
|
* @param id - Feature ID
|
||||||
|
* @param updates - Partial feature updates
|
||||||
|
* @returns The updated feature
|
||||||
|
* @throws Error if feature not found
|
||||||
|
*/
|
||||||
|
updateFeature(id: string, updates: RoadmapFeatureUpdateInput): RoadmapFeature;
|
||||||
|
/**
|
||||||
|
* Delete a feature.
|
||||||
|
*
|
||||||
|
* @param id - Feature ID
|
||||||
|
* @throws Error if feature not found
|
||||||
|
*/
|
||||||
|
deleteFeature(id: string): void;
|
||||||
|
/**
|
||||||
|
* Reorder milestones within a roadmap.
|
||||||
|
*
|
||||||
|
* Applies an explicit reorder input and persists the full normalized order.
|
||||||
|
* The input must contain all milestone IDs exactly once.
|
||||||
|
*
|
||||||
|
* @param input - Reorder input with complete milestone ID list
|
||||||
|
* @returns The reordered milestones in their new order
|
||||||
|
* @throws Error if milestone set is incomplete, duplicate, or not found
|
||||||
|
*/
|
||||||
|
reorderMilestones(input: RoadmapMilestoneReorderInput): RoadmapMilestone[];
|
||||||
|
/**
|
||||||
|
* Reorder features within a milestone.
|
||||||
|
*
|
||||||
|
* Applies an explicit reorder input and persists the full normalized order.
|
||||||
|
* The input must contain all feature IDs for the milestone exactly once.
|
||||||
|
*
|
||||||
|
* @param input - Reorder input with complete feature ID list
|
||||||
|
* @returns The reordered features in their new order
|
||||||
|
* @throws Error if feature set is incomplete, duplicate, or not found
|
||||||
|
*/
|
||||||
|
reorderFeatures(input: RoadmapFeatureReorderInput): RoadmapFeature[];
|
||||||
|
/**
|
||||||
|
* Move a feature, including cross-milestone moves.
|
||||||
|
*
|
||||||
|
* Atomically renumbers both the source and destination milestone scopes.
|
||||||
|
*
|
||||||
|
* @param input - Move input with source/destination milestone info
|
||||||
|
* @returns The moved feature and both affected milestone feature lists
|
||||||
|
* @throws Error if feature or milestone not found, or scope validation fails
|
||||||
|
*/
|
||||||
|
moveFeature(input: RoadmapFeatureMoveInput): {
|
||||||
|
movedFeature: RoadmapFeature;
|
||||||
|
sourceMilestoneFeatures: RoadmapFeature[];
|
||||||
|
targetMilestoneFeatures: RoadmapFeature[];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get a milestone with all of its features in deterministic order.
|
||||||
|
*
|
||||||
|
* @param id - Milestone ID
|
||||||
|
* @returns The milestone with features, or undefined if not found
|
||||||
|
*/
|
||||||
|
getMilestoneWithFeatures(id: string): RoadmapMilestoneWithFeatures | undefined;
|
||||||
|
/**
|
||||||
|
* Get a roadmap with its full hierarchy (milestones → features).
|
||||||
|
*
|
||||||
|
* @param id - Roadmap ID
|
||||||
|
* @returns The roadmap with hierarchy, or undefined if not found
|
||||||
|
*/
|
||||||
|
getRoadmapWithHierarchy(id: string): RoadmapWithHierarchy | undefined;
|
||||||
|
/**
|
||||||
|
* Get a flat export bundle for a roadmap.
|
||||||
|
*
|
||||||
|
* Returns all roadmap data in a flat structure suitable for persistence,
|
||||||
|
* APIs, import/export, and sync jobs. Entities are separated so downstream
|
||||||
|
* persistence layers can upsert by table/collection.
|
||||||
|
*
|
||||||
|
* @param roadmapId - Roadmap ID
|
||||||
|
* @returns The export bundle with ordered entities
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
getRoadmapExport(roadmapId: string): RoadmapExportBundle;
|
||||||
|
/**
|
||||||
|
* Get a mission planning handoff payload for a roadmap.
|
||||||
|
*
|
||||||
|
* Converts the roadmap into a mission planning structure while preserving
|
||||||
|
* source IDs and deterministic order. Does not couple to MissionStore internals.
|
||||||
|
*
|
||||||
|
* @param roadmapId - Roadmap ID
|
||||||
|
* @returns The mission planning handoff payload
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
getRoadmapMissionHandoff(roadmapId: string): RoadmapMissionPlanningHandoff;
|
||||||
|
/**
|
||||||
|
* Get a task planning handoff payload for a single roadmap feature.
|
||||||
|
*
|
||||||
|
* Returns a self-contained handoff payload for converting a roadmap feature
|
||||||
|
* into task planning flows without coupling to MissionStore internals.
|
||||||
|
*
|
||||||
|
* @param roadmapId - Parent roadmap ID (for validation)
|
||||||
|
* @param milestoneId - Parent milestone ID (for validation)
|
||||||
|
* @param featureId - Feature ID to generate handoff for
|
||||||
|
* @returns The task planning handoff payload
|
||||||
|
* @throws Error if any entity is not found or if ownership validation fails
|
||||||
|
*/
|
||||||
|
getRoadmapFeatureHandoff(roadmapId: string, milestoneId: string, featureId: string): RoadmapFeatureTaskPlanningHandoff;
|
||||||
|
/**
|
||||||
|
* Get a mission planning handoff payload for a roadmap.
|
||||||
|
*
|
||||||
|
* Alias for getRoadmapMissionHandoff() for API consistency.
|
||||||
|
* Converts the roadmap into a mission planning structure while preserving
|
||||||
|
* source IDs and deterministic order.
|
||||||
|
*
|
||||||
|
* @param roadmapId - Roadmap ID
|
||||||
|
* @returns The mission planning handoff payload
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
getMissionPlanningHandoff(roadmapId: string): RoadmapMissionPlanningHandoff;
|
||||||
|
/**
|
||||||
|
* List all task planning handoff payloads for a roadmap.
|
||||||
|
*
|
||||||
|
* Returns a flat list of all feature handoffs in deterministic order
|
||||||
|
* (milestone order index, then feature order index).
|
||||||
|
*
|
||||||
|
* @param roadmapId - Roadmap ID
|
||||||
|
* @returns Array of task planning handoff payloads for all features
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
listFeatureTaskPlanningHandoffs(roadmapId: string): RoadmapFeatureTaskPlanningHandoff[];
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=roadmap-store.d.ts.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"roadmap-store.d.ts","sourceRoot":"","sources":["roadmap-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,KAAK,EACV,OAAO,EACP,gBAAgB,EAChB,cAAc,EACd,kBAAkB,EAClB,kBAAkB,EAClB,2BAA2B,EAC3B,2BAA2B,EAC3B,yBAAyB,EACzB,yBAAyB,EACzB,4BAA4B,EAC5B,0BAA0B,EAC1B,uBAAuB,EACvB,4BAA4B,EAC5B,oBAAoB,EACpB,mBAAmB,EACnB,6BAA6B,EAC7B,iCAAiC,EAElC,MAAM,qBAAqB,CAAC;AAS7B,MAAM,WAAW,kBAAkB;IACjC,wCAAwC;IACxC,iBAAiB,EAAE,CAAC,OAAO,CAAC,CAAC;IAC7B,wCAAwC;IACxC,iBAAiB,EAAE,CAAC,OAAO,CAAC,CAAC;IAC7B,wCAAwC;IACxC,iBAAiB,EAAE,CAAC,MAAM,CAAC,CAAC;IAC5B,0CAA0C;IAC1C,mBAAmB,EAAE,CAAC,gBAAgB,CAAC,CAAC;IACxC,0CAA0C;IAC1C,mBAAmB,EAAE,CAAC,gBAAgB,CAAC,CAAC;IACxC,0CAA0C;IAC1C,mBAAmB,EAAE,CAAC,MAAM,CAAC,CAAC;IAC9B,4CAA4C;IAC5C,qBAAqB,EAAE,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,gBAAgB,EAAE,CAAA;KAAE,CAAC,CAAC;IAC/E,wCAAwC;IACxC,iBAAiB,EAAE,CAAC,cAAc,CAAC,CAAC;IACpC,wCAAwC;IACxC,iBAAiB,EAAE,CAAC,cAAc,CAAC,CAAC;IACpC,wCAAwC;IACxC,iBAAiB,EAAE,CAAC,cAAc,CAAC,CAAC;IACpC,6DAA6D;IAC7D,mBAAmB,EAAE,CAAC;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,cAAc,EAAE,CAAA;KAAE,CAAC,CAAC;IAC3E,wEAAwE;IACxE,eAAe,EAAE,CAAC;QAAE,OAAO,EAAE,cAAc,CAAC;QAAC,eAAe,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAChG;AAqCD,qBAAa,YAAa,SAAQ,YAAY,CAAC,kBAAkB,CAAC;IAMpD,OAAO,CAAC,EAAE;IALtB;;;;OAIG;gBACiB,EAAE,EAAE,QAAQ;IAMhC,OAAO,CAAC,YAAY;IA0CpB,OAAO,CAAC,iBAAiB;IAMzB,OAAO,CAAC,mBAAmB;IAM3B,OAAO,CAAC,iBAAiB;IAQzB,OAAO,CAAC,YAAY;IAUpB,OAAO,CAAC,cAAc;IAYtB,OAAO,CAAC,YAAY;IAcpB;;;;;OAKG;IACH,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO;IA4BjD;;;;;OAKG;IACH,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS;IAM3C;;;;OAIG;IACH,YAAY,IAAI,OAAO,EAAE;IAOzB;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO;IAgC/D;;;;;OAKG;IACH,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAe/B;;;;;;;;OAQG;IACH,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,2BAA2B,GAAG,gBAAgB;IA2CxF;;;;;OAKG;IACH,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAMtD;;;;;;;;OAQG;IACH,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB,EAAE;IAOrD;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,2BAA2B,GAAG,gBAAgB;IAiCnF;;;;;OAKG;IACH,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAejC;;;;;;;;OAQG;IACH,aAAa,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,yBAAyB,GAAG,cAAc;IA2CpF;;;;;OAKG;IACH,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;IAMlD;;;;;;;;OAQG;IACH,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,cAAc,EAAE;IAOnD;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,yBAAyB,GAAG,cAAc;IAiC7E;;;;;OAKG;IACH,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAc/B;;;;;;;;;OASG;IACH,iBAAiB,CAAC,KAAK,EAAE,4BAA4B,GAAG,gBAAgB,EAAE;IA4B1E;;;;;;;;;OASG;IACH,eAAe,CAAC,KAAK,EAAE,0BAA0B,GAAG,cAAc,EAAE;IA+BpE;;;;;;;;OAQG;IACH,WAAW,CAAC,KAAK,EAAE,uBAAuB,GAAG;QAC3C,YAAY,EAAE,cAAc,CAAC;QAC7B,uBAAuB,EAAE,cAAc,EAAE,CAAC;QAC1C,uBAAuB,EAAE,cAAc,EAAE,CAAC;KAC3C;IA+DD;;;;;OAKG;IACH,wBAAwB,CAAC,EAAE,EAAE,MAAM,GAAG,4BAA4B,GAAG,SAAS;IAU9E;;;;;OAKG;IACH,uBAAuB,CAAC,EAAE,EAAE,MAAM,GAAG,oBAAoB,GAAG,SAAS;IAerE;;;;;;;;;;OAUG;IACH,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,mBAAmB;IAqBxD;;;;;;;;;OASG;IACH,wBAAwB,CAAC,SAAS,EAAE,MAAM,GAAG,6BAA6B;IA+B1E;;;;;;;;;;;OAWG;IACH,wBAAwB,CACtB,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,iCAAiC;IA2CpC;;;;;;;;;;OAUG;IACH,yBAAyB,CAAC,SAAS,EAAE,MAAM,GAAG,6BAA6B;IAI3E;;;;;;;;;OASG;IACH,+BAA+B,CAAC,SAAS,EAAE,MAAM,GAAG,iCAAiC,EAAE;CAkCxF"}
|
||||||
765
plugins/fusion-plugin-roadmap/src/store/roadmap-store.js
Normal file
765
plugins/fusion-plugin-roadmap/src/store/roadmap-store.js
Normal file
@@ -0,0 +1,765 @@
|
|||||||
|
/**
|
||||||
|
* RoadmapStore - Data layer for standalone roadmap persistence.
|
||||||
|
*
|
||||||
|
* Manages CRUD operations for roadmaps, milestones, and features.
|
||||||
|
* Provides deterministic ordering via covering indexes and atomic reorder/move operations.
|
||||||
|
*
|
||||||
|
* Ordering invariants:
|
||||||
|
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
|
||||||
|
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
|
||||||
|
* - all list/read queries use deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||||
|
* - cross-milestone feature moves atomically renumber both affected milestone scopes
|
||||||
|
*/
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { applyRoadmapMilestoneReorder, applyRoadmapFeatureReorder, moveRoadmapFeature, } from "./roadmap-ordering.js";
|
||||||
|
// ── RoadmapStore Class ──────────────────────────────────────────────
|
||||||
|
export class RoadmapStore extends EventEmitter {
|
||||||
|
db;
|
||||||
|
/**
|
||||||
|
* Creates a new RoadmapStore instance.
|
||||||
|
*
|
||||||
|
* @param db - Shared Database instance (same instance used by TaskStore)
|
||||||
|
*/
|
||||||
|
constructor(db) {
|
||||||
|
super();
|
||||||
|
this.db = db;
|
||||||
|
this.setMaxListeners(50);
|
||||||
|
this.ensureSchema();
|
||||||
|
}
|
||||||
|
ensureSchema() {
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS roadmaps (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS roadmap_milestones (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
roadmapId TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
orderIndex INTEGER NOT NULL,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (roadmapId) REFERENCES roadmaps(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS roadmap_features (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
milestoneId TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
orderIndex INTEGER NOT NULL,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (milestoneId) REFERENCES roadmap_milestones(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idxRoadmapMilestonesRoadmapOrder
|
||||||
|
ON roadmap_milestones(roadmapId, orderIndex, createdAt, id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idxRoadmapFeaturesMilestoneOrder
|
||||||
|
ON roadmap_features(milestoneId, orderIndex, createdAt, id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
// ── ID Generators ───────────────────────────────────────────────────
|
||||||
|
generateRoadmapId() {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||||
|
return `RM-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||||
|
}
|
||||||
|
generateMilestoneId() {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||||
|
return `RMS-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||||
|
}
|
||||||
|
generateFeatureId() {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||||
|
return `RF-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||||
|
}
|
||||||
|
// ── Row-to-Object Converters ───────────────────────────────────────
|
||||||
|
rowToRoadmap(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description || undefined,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
rowToMilestone(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
roadmapId: row.roadmapId,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description || undefined,
|
||||||
|
orderIndex: row.orderIndex,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
rowToFeature(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
milestoneId: row.milestoneId,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description || undefined,
|
||||||
|
orderIndex: row.orderIndex,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ── Roadmap CRUD ─────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Create a new roadmap.
|
||||||
|
*
|
||||||
|
* @param input - Roadmap creation input
|
||||||
|
* @returns The created roadmap
|
||||||
|
*/
|
||||||
|
createRoadmap(input) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const id = this.generateRoadmapId();
|
||||||
|
const roadmap = {
|
||||||
|
id,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO roadmaps (id, title, description, createdAt, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(roadmap.id, roadmap.title, roadmap.description ?? null, roadmap.createdAt, roadmap.updatedAt);
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("roadmap:created", roadmap);
|
||||||
|
return roadmap;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get a roadmap by ID.
|
||||||
|
*
|
||||||
|
* @param id - Roadmap ID
|
||||||
|
* @returns The roadmap, or undefined if not found
|
||||||
|
*/
|
||||||
|
getRoadmap(id) {
|
||||||
|
const row = this.db.prepare("SELECT * FROM roadmaps WHERE id = ?").get(id);
|
||||||
|
if (!row)
|
||||||
|
return undefined;
|
||||||
|
return this.rowToRoadmap(row);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* List all roadmaps, ordered by creation date (newest first).
|
||||||
|
*
|
||||||
|
* @returns Array of roadmaps
|
||||||
|
*/
|
||||||
|
listRoadmaps() {
|
||||||
|
const rows = this.db.prepare("SELECT * FROM roadmaps ORDER BY createdAt DESC").all();
|
||||||
|
return rows.map((row) => this.rowToRoadmap(row));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Update a roadmap.
|
||||||
|
*
|
||||||
|
* @param id - Roadmap ID
|
||||||
|
* @param updates - Partial roadmap updates
|
||||||
|
* @returns The updated roadmap
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
updateRoadmap(id, updates) {
|
||||||
|
const roadmap = this.getRoadmap(id);
|
||||||
|
if (!roadmap) {
|
||||||
|
throw new Error(`Roadmap ${id} not found`);
|
||||||
|
}
|
||||||
|
const updated = {
|
||||||
|
...roadmap,
|
||||||
|
...updates,
|
||||||
|
id, // Prevent changing ID
|
||||||
|
createdAt: roadmap.createdAt, // Prevent changing creation time
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.db.prepare(`
|
||||||
|
UPDATE roadmaps SET
|
||||||
|
title = ?,
|
||||||
|
description = ?,
|
||||||
|
updatedAt = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(updated.title, updated.description ?? null, updated.updatedAt, updated.id);
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("roadmap:updated", updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Delete a roadmap and all its milestones/features (cascading).
|
||||||
|
*
|
||||||
|
* @param id - Roadmap ID
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
deleteRoadmap(id) {
|
||||||
|
const roadmap = this.getRoadmap(id);
|
||||||
|
if (!roadmap) {
|
||||||
|
throw new Error(`Roadmap ${id} not found`);
|
||||||
|
}
|
||||||
|
// SQLite FK cascade will handle milestones and features
|
||||||
|
this.db.prepare("DELETE FROM roadmaps WHERE id = ?").run(id);
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("roadmap:deleted", id);
|
||||||
|
}
|
||||||
|
// ── Milestone CRUD ────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Add a milestone to a roadmap.
|
||||||
|
* Automatically computes the orderIndex (max + 1).
|
||||||
|
*
|
||||||
|
* @param roadmapId - Parent roadmap ID
|
||||||
|
* @param input - Milestone creation input
|
||||||
|
* @returns The created milestone
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
createMilestone(roadmapId, input) {
|
||||||
|
const roadmap = this.getRoadmap(roadmapId);
|
||||||
|
if (!roadmap) {
|
||||||
|
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const id = this.generateMilestoneId();
|
||||||
|
// Compute next orderIndex
|
||||||
|
const existingMilestones = this.listMilestones(roadmapId);
|
||||||
|
const orderIndex = existingMilestones.length > 0
|
||||||
|
? Math.max(...existingMilestones.map((m) => m.orderIndex)) + 1
|
||||||
|
: 0;
|
||||||
|
const milestone = {
|
||||||
|
id,
|
||||||
|
roadmapId,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
orderIndex,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO roadmap_milestones (id, roadmapId, title, description, orderIndex, createdAt, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(milestone.id, milestone.roadmapId, milestone.title, milestone.description ?? null, milestone.orderIndex, milestone.createdAt, milestone.updatedAt);
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("milestone:created", milestone);
|
||||||
|
return milestone;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get a milestone by ID.
|
||||||
|
*
|
||||||
|
* @param id - Milestone ID
|
||||||
|
* @returns The milestone, or undefined if not found
|
||||||
|
*/
|
||||||
|
getMilestone(id) {
|
||||||
|
const row = this.db.prepare("SELECT * FROM roadmap_milestones WHERE id = ?").get(id);
|
||||||
|
if (!row)
|
||||||
|
return undefined;
|
||||||
|
return this.rowToMilestone(row);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* List milestones for a roadmap, ordered deterministically.
|
||||||
|
*
|
||||||
|
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||||
|
* to ensure consistent results when stored order data is incomplete or conflicting.
|
||||||
|
*
|
||||||
|
* @param roadmapId - Roadmap ID
|
||||||
|
* @returns Array of milestones in deterministic order
|
||||||
|
*/
|
||||||
|
listMilestones(roadmapId) {
|
||||||
|
const rows = this.db.prepare("SELECT * FROM roadmap_milestones WHERE roadmapId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC").all(roadmapId);
|
||||||
|
return rows.map((row) => this.rowToMilestone(row));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Update a milestone.
|
||||||
|
*
|
||||||
|
* @param id - Milestone ID
|
||||||
|
* @param updates - Partial milestone updates
|
||||||
|
* @returns The updated milestone
|
||||||
|
* @throws Error if milestone not found
|
||||||
|
*/
|
||||||
|
updateMilestone(id, updates) {
|
||||||
|
const milestone = this.getMilestone(id);
|
||||||
|
if (!milestone) {
|
||||||
|
throw new Error(`Milestone ${id} not found`);
|
||||||
|
}
|
||||||
|
const updated = {
|
||||||
|
...milestone,
|
||||||
|
...updates,
|
||||||
|
id, // Prevent changing ID
|
||||||
|
roadmapId: milestone.roadmapId, // Prevent moving to different roadmap
|
||||||
|
createdAt: milestone.createdAt, // Prevent changing creation time
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.db.prepare(`
|
||||||
|
UPDATE roadmap_milestones SET
|
||||||
|
title = ?,
|
||||||
|
description = ?,
|
||||||
|
updatedAt = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(updated.title, updated.description ?? null, updated.updatedAt, updated.id);
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("milestone:updated", updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Delete a milestone and all its features (cascading).
|
||||||
|
*
|
||||||
|
* @param id - Milestone ID
|
||||||
|
* @throws Error if milestone not found
|
||||||
|
*/
|
||||||
|
deleteMilestone(id) {
|
||||||
|
const milestone = this.getMilestone(id);
|
||||||
|
if (!milestone) {
|
||||||
|
throw new Error(`Milestone ${id} not found`);
|
||||||
|
}
|
||||||
|
// SQLite FK cascade will handle features
|
||||||
|
this.db.prepare("DELETE FROM roadmap_milestones WHERE id = ?").run(id);
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("milestone:deleted", id);
|
||||||
|
}
|
||||||
|
// ── Feature CRUD ─────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Add a feature to a milestone.
|
||||||
|
* Automatically computes the orderIndex (max + 1).
|
||||||
|
*
|
||||||
|
* @param milestoneId - Parent milestone ID
|
||||||
|
* @param input - Feature creation input
|
||||||
|
* @returns The created feature
|
||||||
|
* @throws Error if milestone not found
|
||||||
|
*/
|
||||||
|
createFeature(milestoneId, input) {
|
||||||
|
const milestone = this.getMilestone(milestoneId);
|
||||||
|
if (!milestone) {
|
||||||
|
throw new Error(`Milestone ${milestoneId} not found`);
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const id = this.generateFeatureId();
|
||||||
|
// Compute next orderIndex
|
||||||
|
const existingFeatures = this.listFeatures(milestoneId);
|
||||||
|
const orderIndex = existingFeatures.length > 0
|
||||||
|
? Math.max(...existingFeatures.map((f) => f.orderIndex)) + 1
|
||||||
|
: 0;
|
||||||
|
const feature = {
|
||||||
|
id,
|
||||||
|
milestoneId,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
orderIndex,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO roadmap_features (id, milestoneId, title, description, orderIndex, createdAt, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(feature.id, feature.milestoneId, feature.title, feature.description ?? null, feature.orderIndex, feature.createdAt, feature.updatedAt);
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("feature:created", feature);
|
||||||
|
return feature;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get a feature by ID.
|
||||||
|
*
|
||||||
|
* @param id - Feature ID
|
||||||
|
* @returns The feature, or undefined if not found
|
||||||
|
*/
|
||||||
|
getFeature(id) {
|
||||||
|
const row = this.db.prepare("SELECT * FROM roadmap_features WHERE id = ?").get(id);
|
||||||
|
if (!row)
|
||||||
|
return undefined;
|
||||||
|
return this.rowToFeature(row);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* List features for a milestone, ordered deterministically.
|
||||||
|
*
|
||||||
|
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||||
|
* to ensure consistent results when stored order data is incomplete or conflicting.
|
||||||
|
*
|
||||||
|
* @param milestoneId - Milestone ID
|
||||||
|
* @returns Array of features in deterministic order
|
||||||
|
*/
|
||||||
|
listFeatures(milestoneId) {
|
||||||
|
const rows = this.db.prepare("SELECT * FROM roadmap_features WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC").all(milestoneId);
|
||||||
|
return rows.map((row) => this.rowToFeature(row));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Update a feature.
|
||||||
|
*
|
||||||
|
* @param id - Feature ID
|
||||||
|
* @param updates - Partial feature updates
|
||||||
|
* @returns The updated feature
|
||||||
|
* @throws Error if feature not found
|
||||||
|
*/
|
||||||
|
updateFeature(id, updates) {
|
||||||
|
const feature = this.getFeature(id);
|
||||||
|
if (!feature) {
|
||||||
|
throw new Error(`Feature ${id} not found`);
|
||||||
|
}
|
||||||
|
const updated = {
|
||||||
|
...feature,
|
||||||
|
...updates,
|
||||||
|
id, // Prevent changing ID
|
||||||
|
milestoneId: feature.milestoneId, // Prevent moving via update (use moveFeature instead)
|
||||||
|
createdAt: feature.createdAt, // Prevent changing creation time
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.db.prepare(`
|
||||||
|
UPDATE roadmap_features SET
|
||||||
|
title = ?,
|
||||||
|
description = ?,
|
||||||
|
updatedAt = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(updated.title, updated.description ?? null, updated.updatedAt, updated.id);
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("feature:updated", updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Delete a feature.
|
||||||
|
*
|
||||||
|
* @param id - Feature ID
|
||||||
|
* @throws Error if feature not found
|
||||||
|
*/
|
||||||
|
deleteFeature(id) {
|
||||||
|
const feature = this.getFeature(id);
|
||||||
|
if (!feature) {
|
||||||
|
throw new Error(`Feature ${id} not found`);
|
||||||
|
}
|
||||||
|
this.db.prepare("DELETE FROM roadmap_features WHERE id = ?").run(id);
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("feature:deleted", feature);
|
||||||
|
}
|
||||||
|
// ── Reorder Operations ────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Reorder milestones within a roadmap.
|
||||||
|
*
|
||||||
|
* Applies an explicit reorder input and persists the full normalized order.
|
||||||
|
* The input must contain all milestone IDs exactly once.
|
||||||
|
*
|
||||||
|
* @param input - Reorder input with complete milestone ID list
|
||||||
|
* @returns The reordered milestones in their new order
|
||||||
|
* @throws Error if milestone set is incomplete, duplicate, or not found
|
||||||
|
*/
|
||||||
|
reorderMilestones(input) {
|
||||||
|
// Validate roadmap exists
|
||||||
|
const roadmap = this.getRoadmap(input.roadmapId);
|
||||||
|
if (!roadmap) {
|
||||||
|
throw new Error(`Roadmap ${input.roadmapId} not found`);
|
||||||
|
}
|
||||||
|
// Load current milestones with deterministic ordering
|
||||||
|
const milestones = this.listMilestones(input.roadmapId);
|
||||||
|
// Apply the reorder using the pure ordering helper
|
||||||
|
const reordered = applyRoadmapMilestoneReorder(milestones, input);
|
||||||
|
// Persist in a transaction
|
||||||
|
this.db.transaction(() => {
|
||||||
|
for (const milestone of reordered) {
|
||||||
|
this.db.prepare(`
|
||||||
|
UPDATE roadmap_milestones SET orderIndex = ?, updatedAt = ? WHERE id = ?
|
||||||
|
`).run(milestone.orderIndex, new Date().toISOString(), milestone.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("milestone:reordered", { roadmapId: input.roadmapId, milestones: reordered });
|
||||||
|
return reordered;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Reorder features within a milestone.
|
||||||
|
*
|
||||||
|
* Applies an explicit reorder input and persists the full normalized order.
|
||||||
|
* The input must contain all feature IDs for the milestone exactly once.
|
||||||
|
*
|
||||||
|
* @param input - Reorder input with complete feature ID list
|
||||||
|
* @returns The reordered features in their new order
|
||||||
|
* @throws Error if feature set is incomplete, duplicate, or not found
|
||||||
|
*/
|
||||||
|
reorderFeatures(input) {
|
||||||
|
// Validate milestone exists and belongs to the roadmap
|
||||||
|
const milestone = this.getMilestone(input.milestoneId);
|
||||||
|
if (!milestone) {
|
||||||
|
throw new Error(`Milestone ${input.milestoneId} not found`);
|
||||||
|
}
|
||||||
|
if (milestone.roadmapId !== input.roadmapId) {
|
||||||
|
throw new Error(`Milestone ${input.milestoneId} does not belong to roadmap ${input.roadmapId}`);
|
||||||
|
}
|
||||||
|
// Load current features with deterministic ordering
|
||||||
|
const features = this.listFeatures(input.milestoneId);
|
||||||
|
// Apply the reorder using the pure ordering helper
|
||||||
|
const reordered = applyRoadmapFeatureReorder(features, input);
|
||||||
|
// Persist in a transaction
|
||||||
|
this.db.transaction(() => {
|
||||||
|
for (const feature of reordered) {
|
||||||
|
this.db.prepare(`
|
||||||
|
UPDATE roadmap_features SET orderIndex = ?, updatedAt = ? WHERE id = ?
|
||||||
|
`).run(feature.orderIndex, new Date().toISOString(), feature.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("feature:reordered", { milestoneId: input.milestoneId, features: reordered });
|
||||||
|
return reordered;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Move a feature, including cross-milestone moves.
|
||||||
|
*
|
||||||
|
* Atomically renumbers both the source and destination milestone scopes.
|
||||||
|
*
|
||||||
|
* @param input - Move input with source/destination milestone info
|
||||||
|
* @returns The moved feature and both affected milestone feature lists
|
||||||
|
* @throws Error if feature or milestone not found, or scope validation fails
|
||||||
|
*/
|
||||||
|
moveFeature(input) {
|
||||||
|
// Validate roadmap exists
|
||||||
|
const roadmap = this.getRoadmap(input.roadmapId);
|
||||||
|
if (!roadmap) {
|
||||||
|
throw new Error(`Roadmap ${input.roadmapId} not found`);
|
||||||
|
}
|
||||||
|
// Validate both milestones exist and belong to the roadmap
|
||||||
|
const fromMilestone = this.getMilestone(input.fromMilestoneId);
|
||||||
|
const toMilestone = this.getMilestone(input.toMilestoneId);
|
||||||
|
if (!fromMilestone) {
|
||||||
|
throw new Error(`Source milestone ${input.fromMilestoneId} not found`);
|
||||||
|
}
|
||||||
|
if (!toMilestone) {
|
||||||
|
throw new Error(`Destination milestone ${input.toMilestoneId} not found`);
|
||||||
|
}
|
||||||
|
if (fromMilestone.roadmapId !== input.roadmapId) {
|
||||||
|
throw new Error(`Source milestone ${input.fromMilestoneId} does not belong to roadmap ${input.roadmapId}`);
|
||||||
|
}
|
||||||
|
if (toMilestone.roadmapId !== input.roadmapId) {
|
||||||
|
throw new Error(`Destination milestone ${input.toMilestoneId} does not belong to roadmap ${input.roadmapId}`);
|
||||||
|
}
|
||||||
|
// Load features from both milestones with deterministic ordering
|
||||||
|
const sourceFeatures = this.listFeatures(input.fromMilestoneId);
|
||||||
|
const targetFeatures = this.listFeatures(input.toMilestoneId);
|
||||||
|
// For same-milestone moves, pass only one list to avoid duplication
|
||||||
|
// For cross-milestone moves, pass the combined list
|
||||||
|
const allFeatures = input.fromMilestoneId === input.toMilestoneId
|
||||||
|
? sourceFeatures
|
||||||
|
: [...sourceFeatures, ...targetFeatures];
|
||||||
|
// Apply the move using the pure ordering helper
|
||||||
|
const result = moveRoadmapFeature(allFeatures, input);
|
||||||
|
// Persist in a transaction
|
||||||
|
this.db.transaction(() => {
|
||||||
|
// Update all affected features
|
||||||
|
for (const feature of result.affectedFeatures) {
|
||||||
|
this.db.prepare(`
|
||||||
|
UPDATE roadmap_features SET milestoneId = ?, orderIndex = ?, updatedAt = ? WHERE id = ?
|
||||||
|
`).run(feature.milestoneId, feature.orderIndex, new Date().toISOString(), feature.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("feature:moved", {
|
||||||
|
feature: result.movedFeature,
|
||||||
|
fromMilestoneId: input.fromMilestoneId,
|
||||||
|
toMilestoneId: input.toMilestoneId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
movedFeature: result.movedFeature,
|
||||||
|
sourceMilestoneFeatures: result.sourceMilestoneFeatures,
|
||||||
|
targetMilestoneFeatures: result.targetMilestoneFeatures,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ── Hierarchy Operations ───────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Get a milestone with all of its features in deterministic order.
|
||||||
|
*
|
||||||
|
* @param id - Milestone ID
|
||||||
|
* @returns The milestone with features, or undefined if not found
|
||||||
|
*/
|
||||||
|
getMilestoneWithFeatures(id) {
|
||||||
|
const milestone = this.getMilestone(id);
|
||||||
|
if (!milestone)
|
||||||
|
return undefined;
|
||||||
|
return {
|
||||||
|
...milestone,
|
||||||
|
features: this.listFeatures(id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get a roadmap with its full hierarchy (milestones → features).
|
||||||
|
*
|
||||||
|
* @param id - Roadmap ID
|
||||||
|
* @returns The roadmap with hierarchy, or undefined if not found
|
||||||
|
*/
|
||||||
|
getRoadmapWithHierarchy(id) {
|
||||||
|
const roadmap = this.getRoadmap(id);
|
||||||
|
if (!roadmap)
|
||||||
|
return undefined;
|
||||||
|
return {
|
||||||
|
...roadmap,
|
||||||
|
milestones: this.listMilestones(id).map((milestone) => ({
|
||||||
|
...milestone,
|
||||||
|
features: this.listFeatures(milestone.id),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ── Export / Handoff Operations ────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Get a flat export bundle for a roadmap.
|
||||||
|
*
|
||||||
|
* Returns all roadmap data in a flat structure suitable for persistence,
|
||||||
|
* APIs, import/export, and sync jobs. Entities are separated so downstream
|
||||||
|
* persistence layers can upsert by table/collection.
|
||||||
|
*
|
||||||
|
* @param roadmapId - Roadmap ID
|
||||||
|
* @returns The export bundle with ordered entities
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
getRoadmapExport(roadmapId) {
|
||||||
|
const roadmap = this.getRoadmap(roadmapId);
|
||||||
|
if (!roadmap) {
|
||||||
|
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||||
|
}
|
||||||
|
const milestones = this.listMilestones(roadmapId);
|
||||||
|
const allFeatures = [];
|
||||||
|
for (const milestone of milestones) {
|
||||||
|
const features = this.listFeatures(milestone.id);
|
||||||
|
allFeatures.push(...features);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
roadmap,
|
||||||
|
milestones,
|
||||||
|
features: allFeatures,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get a mission planning handoff payload for a roadmap.
|
||||||
|
*
|
||||||
|
* Converts the roadmap into a mission planning structure while preserving
|
||||||
|
* source IDs and deterministic order. Does not couple to MissionStore internals.
|
||||||
|
*
|
||||||
|
* @param roadmapId - Roadmap ID
|
||||||
|
* @returns The mission planning handoff payload
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
getRoadmapMissionHandoff(roadmapId) {
|
||||||
|
const roadmap = this.getRoadmap(roadmapId);
|
||||||
|
if (!roadmap) {
|
||||||
|
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||||
|
}
|
||||||
|
const milestones = this.listMilestones(roadmapId);
|
||||||
|
return {
|
||||||
|
sourceRoadmapId: roadmap.id,
|
||||||
|
title: roadmap.title,
|
||||||
|
description: roadmap.description,
|
||||||
|
milestones: milestones.map((milestone) => {
|
||||||
|
const features = this.listFeatures(milestone.id);
|
||||||
|
return {
|
||||||
|
sourceMilestoneId: milestone.id,
|
||||||
|
title: milestone.title,
|
||||||
|
description: milestone.description,
|
||||||
|
orderIndex: milestone.orderIndex,
|
||||||
|
features: features.map((feature) => ({
|
||||||
|
sourceFeatureId: feature.id,
|
||||||
|
title: feature.title,
|
||||||
|
description: feature.description,
|
||||||
|
orderIndex: feature.orderIndex,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get a task planning handoff payload for a single roadmap feature.
|
||||||
|
*
|
||||||
|
* Returns a self-contained handoff payload for converting a roadmap feature
|
||||||
|
* into task planning flows without coupling to MissionStore internals.
|
||||||
|
*
|
||||||
|
* @param roadmapId - Parent roadmap ID (for validation)
|
||||||
|
* @param milestoneId - Parent milestone ID (for validation)
|
||||||
|
* @param featureId - Feature ID to generate handoff for
|
||||||
|
* @returns The task planning handoff payload
|
||||||
|
* @throws Error if any entity is not found or if ownership validation fails
|
||||||
|
*/
|
||||||
|
getRoadmapFeatureHandoff(roadmapId, milestoneId, featureId) {
|
||||||
|
// Validate roadmap exists
|
||||||
|
const roadmap = this.getRoadmap(roadmapId);
|
||||||
|
if (!roadmap) {
|
||||||
|
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||||
|
}
|
||||||
|
// Validate milestone exists and belongs to roadmap
|
||||||
|
const milestone = this.getMilestone(milestoneId);
|
||||||
|
if (!milestone) {
|
||||||
|
throw new Error(`Milestone ${milestoneId} not found`);
|
||||||
|
}
|
||||||
|
if (milestone.roadmapId !== roadmapId) {
|
||||||
|
throw new Error(`Milestone ${milestoneId} does not belong to roadmap ${roadmapId}`);
|
||||||
|
}
|
||||||
|
// Validate feature exists and belongs to milestone
|
||||||
|
const feature = this.getFeature(featureId);
|
||||||
|
if (!feature) {
|
||||||
|
throw new Error(`Feature ${featureId} not found`);
|
||||||
|
}
|
||||||
|
if (feature.milestoneId !== milestoneId) {
|
||||||
|
throw new Error(`Feature ${featureId} does not belong to milestone ${milestoneId}`);
|
||||||
|
}
|
||||||
|
// Build the source reference with ordering context
|
||||||
|
const source = {
|
||||||
|
roadmapId: roadmap.id,
|
||||||
|
milestoneId: milestone.id,
|
||||||
|
featureId: feature.id,
|
||||||
|
roadmapTitle: roadmap.title,
|
||||||
|
milestoneTitle: milestone.title,
|
||||||
|
milestoneOrderIndex: milestone.orderIndex,
|
||||||
|
featureOrderIndex: feature.orderIndex,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
title: feature.title,
|
||||||
|
description: feature.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get a mission planning handoff payload for a roadmap.
|
||||||
|
*
|
||||||
|
* Alias for getRoadmapMissionHandoff() for API consistency.
|
||||||
|
* Converts the roadmap into a mission planning structure while preserving
|
||||||
|
* source IDs and deterministic order.
|
||||||
|
*
|
||||||
|
* @param roadmapId - Roadmap ID
|
||||||
|
* @returns The mission planning handoff payload
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
getMissionPlanningHandoff(roadmapId) {
|
||||||
|
return this.getRoadmapMissionHandoff(roadmapId);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* List all task planning handoff payloads for a roadmap.
|
||||||
|
*
|
||||||
|
* Returns a flat list of all feature handoffs in deterministic order
|
||||||
|
* (milestone order index, then feature order index).
|
||||||
|
*
|
||||||
|
* @param roadmapId - Roadmap ID
|
||||||
|
* @returns Array of task planning handoff payloads for all features
|
||||||
|
* @throws Error if roadmap not found
|
||||||
|
*/
|
||||||
|
listFeatureTaskPlanningHandoffs(roadmapId) {
|
||||||
|
// Validate roadmap exists
|
||||||
|
const roadmap = this.getRoadmap(roadmapId);
|
||||||
|
if (!roadmap) {
|
||||||
|
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||||
|
}
|
||||||
|
const milestones = this.listMilestones(roadmapId);
|
||||||
|
const handoffs = [];
|
||||||
|
for (const milestone of milestones) {
|
||||||
|
const features = this.listFeatures(milestone.id);
|
||||||
|
for (const feature of features) {
|
||||||
|
const source = {
|
||||||
|
roadmapId: roadmap.id,
|
||||||
|
milestoneId: milestone.id,
|
||||||
|
featureId: feature.id,
|
||||||
|
roadmapTitle: roadmap.title,
|
||||||
|
milestoneTitle: milestone.title,
|
||||||
|
milestoneOrderIndex: milestone.orderIndex,
|
||||||
|
featureOrderIndex: feature.orderIndex,
|
||||||
|
};
|
||||||
|
handoffs.push({
|
||||||
|
source,
|
||||||
|
title: feature.title,
|
||||||
|
description: feature.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return handoffs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=roadmap-store.js.map
|
||||||
File diff suppressed because one or more lines are too long
@@ -112,6 +112,47 @@ export class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
|
|||||||
constructor(private db: Database) {
|
constructor(private db: Database) {
|
||||||
super();
|
super();
|
||||||
this.setMaxListeners(50);
|
this.setMaxListeners(50);
|
||||||
|
this.ensureSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureSchema(): void {
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS roadmaps (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS roadmap_milestones (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
roadmapId TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
orderIndex INTEGER NOT NULL,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (roadmapId) REFERENCES roadmaps(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS roadmap_features (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
milestoneId TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
orderIndex INTEGER NOT NULL,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (milestoneId) REFERENCES roadmap_milestones(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idxRoadmapMilestonesRoadmapOrder
|
||||||
|
ON roadmap_milestones(roadmapId, orderIndex, createdAt, id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idxRoadmapFeaturesMilestoneOrder
|
||||||
|
ON roadmap_features(milestoneId, orderIndex, createdAt, id);
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ID Generators ───────────────────────────────────────────────────
|
// ── ID Generators ───────────────────────────────────────────────────
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -214,6 +214,9 @@ importers:
|
|||||||
'@fusion-plugin-examples/paperclip-runtime':
|
'@fusion-plugin-examples/paperclip-runtime':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../plugins/fusion-plugin-paperclip-runtime
|
version: link:../../plugins/fusion-plugin-paperclip-runtime
|
||||||
|
'@fusion-plugin-examples/roadmap':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../plugins/fusion-plugin-roadmap
|
||||||
'@fusion/core':
|
'@fusion/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core
|
version: link:../core
|
||||||
@@ -768,6 +771,9 @@ importers:
|
|||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/express':
|
||||||
|
specifier: ^5.0.5
|
||||||
|
version: 5.0.6
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.5.2
|
specifier: ^25.5.2
|
||||||
version: 25.5.2
|
version: 25.5.2
|
||||||
|
|||||||
Reference in New Issue
Block a user