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

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

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

View File

@@ -1,6 +1,6 @@
import type { Database } from "@fusion/core";
import { definePlugin } from "@fusion/plugin-sdk";
import { createRoadmapPluginRoutes } from "./roadmap-routes.js";
import { createRoadmapPluginRoutes } from "./routes/roadmap-routes.js";
export function ensureRoadmapSchema(db: Database): void {
db.exec(`

View File

@@ -1,3 +1 @@
export function createRoadmapPluginRoutes(): [] {
return [];
}
export { createRoadmapPluginRoutes, SUGGESTION_TIMEOUT_MS } from "./routes/roadmap-routes.js";

View 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";

View File

@@ -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";

View 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

View File

@@ -0,0 +1 @@
{"version":3,"file":"roadmap-types.d.ts","sourceRoot":"","sources":["roadmap-types.ts"],"names":[],"mappings":"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"}

View 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

View File

@@ -0,0 +1 @@
{"version":3,"file":"roadmap-types.js","sourceRoot":"","sources":["roadmap-types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG"}

View 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

View File

@@ -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"}

View 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

View 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 };

View 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

View File

@@ -0,0 +1 @@
{"version":3,"file":"roadmap-suggestions.d.ts","sourceRoot":"","sources":["roadmap-suggestions.ts"],"names":[],"mappings":"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"}

View 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

View 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;
}

View File

@@ -0,0 +1 @@
export { createRoadmapPluginRoutes } from "../routes/roadmap-routes.js";

View File

@@ -1 +1 @@
export { createRoadmapPluginRoutes } from "../roadmap-routes.js";
export { createRoadmapPluginRoutes } from "../routes/roadmap-routes.js";

View 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

View File

@@ -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"}

View 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

View 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

View File

@@ -0,0 +1 @@
{"version":3,"file":"roadmap-store.d.ts","sourceRoot":"","sources":["roadmap-store.ts"],"names":[],"mappings":"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"}

View 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

View File

@@ -112,6 +112,47 @@ export class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
constructor(private db: Database) {
super();
this.setMaxListeners(50);
this.ensureSchema();
}
private ensureSchema(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS roadmaps (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS roadmap_milestones (
id TEXT PRIMARY KEY,
roadmapId TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
orderIndex INTEGER NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (roadmapId) REFERENCES roadmaps(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS roadmap_features (
id TEXT PRIMARY KEY,
milestoneId TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
orderIndex INTEGER NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (milestoneId) REFERENCES roadmap_milestones(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxRoadmapMilestonesRoadmapOrder
ON roadmap_milestones(roadmapId, orderIndex, createdAt, id);
CREATE INDEX IF NOT EXISTS idxRoadmapFeaturesMilestoneOrder
ON roadmap_features(milestoneId, orderIndex, createdAt, id);
`);
}
// ── ID Generators ───────────────────────────────────────────────────