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:
@@ -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(`
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
export function createRoadmapPluginRoutes(): [] {
|
||||
return [];
|
||||
}
|
||||
export { createRoadmapPluginRoutes, SUGGESTION_TIMEOUT_MS } from "./routes/roadmap-routes.js";
|
||||
|
||||
15
plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts
vendored
Normal file
15
plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
FEATURE_SUGGESTION_SYSTEM_PROMPT,
|
||||
MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
ParseError,
|
||||
ServiceUnavailableError,
|
||||
SUGGESTION_TIMEOUT_MS,
|
||||
ValidationError,
|
||||
__resetSuggestionState,
|
||||
__setCreateAiSessionFactory,
|
||||
__setCreateFnAgent,
|
||||
generateFeatureSuggestions,
|
||||
generateMilestoneSuggestions,
|
||||
validateFeatureSuggestionInput,
|
||||
validateSuggestionInput,
|
||||
} from "./routes/roadmap-suggestions.js";
|
||||
@@ -1 +1,15 @@
|
||||
export {};
|
||||
export {
|
||||
FEATURE_SUGGESTION_SYSTEM_PROMPT,
|
||||
MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
ParseError,
|
||||
ServiceUnavailableError,
|
||||
SUGGESTION_TIMEOUT_MS,
|
||||
ValidationError,
|
||||
__resetSuggestionState,
|
||||
__setCreateAiSessionFactory,
|
||||
__setCreateFnAgent,
|
||||
generateFeatureSuggestions,
|
||||
generateMilestoneSuggestions,
|
||||
validateFeatureSuggestionInput,
|
||||
validateSuggestionInput,
|
||||
} from "./routes/roadmap-suggestions.js";
|
||||
|
||||
283
plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts
vendored
Normal file
283
plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts
vendored
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Standalone roadmap planning types.
|
||||
*
|
||||
* This model is intentionally separate from the mission hierarchy so roadmap
|
||||
* work can evolve independently of `MissionStore`/`MissionManager`.
|
||||
*
|
||||
* Core ordering invariants:
|
||||
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
|
||||
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
|
||||
* - cross-milestone feature moves must renumber both the source and target
|
||||
* milestone deterministically after the move
|
||||
* - whenever stored order data is incomplete or conflicting, consumers should
|
||||
* repair it using a stable tie-breaker (`createdAt`, then `id`, both ASC)
|
||||
*
|
||||
* These contracts are persistence-agnostic and UI-agnostic. They define the
|
||||
* canonical domain surface that downstream storage, API, and dashboard work use.
|
||||
*
|
||||
* @module roadmap-types
|
||||
*/
|
||||
/**
|
||||
* A standalone roadmap container.
|
||||
*
|
||||
* Roadmaps do not reuse mission lifecycle or mission status concepts. They are
|
||||
* lightweight planning artifacts that own ordered milestones.
|
||||
*/
|
||||
export interface Roadmap {
|
||||
/** Unique identifier (for example `RM-01HXYZ...`) */
|
||||
id: string;
|
||||
/** Display title shown in roadmap lists and detail views */
|
||||
title: string;
|
||||
/** Optional long-form planning context for the roadmap */
|
||||
description?: string;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
/**
|
||||
* A milestone within a roadmap.
|
||||
*
|
||||
* `orderIndex` is the canonical persisted ordering field. It is always scoped to
|
||||
* the parent roadmap and must remain contiguous + 0-based after reorder flows.
|
||||
*/
|
||||
export interface RoadmapMilestone {
|
||||
/** Unique identifier (for example `RMS-01HXYZ...`) */
|
||||
id: string;
|
||||
/** Parent roadmap ID */
|
||||
roadmapId: string;
|
||||
/** Display title for the milestone */
|
||||
title: string;
|
||||
/** Optional description of the milestone's goals */
|
||||
description?: string;
|
||||
/** 0-based contiguous ordering within the roadmap */
|
||||
orderIndex: number;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
/**
|
||||
* A feature within a roadmap milestone.
|
||||
*
|
||||
* `orderIndex` is scoped to the parent milestone. Cross-milestone moves must
|
||||
* update `milestoneId` and then normalize both affected milestone lists back to
|
||||
* contiguous 0-based order.
|
||||
*/
|
||||
export interface RoadmapFeature {
|
||||
/** Unique identifier (for example `RF-01HXYZ...`) */
|
||||
id: string;
|
||||
/** Parent milestone ID */
|
||||
milestoneId: string;
|
||||
/** Display title for the feature */
|
||||
title: string;
|
||||
/** Optional description of the feature's intent */
|
||||
description?: string;
|
||||
/** 0-based contiguous ordering within the parent milestone */
|
||||
orderIndex: number;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
/** Input for creating a roadmap. */
|
||||
export interface RoadmapCreateInput {
|
||||
/** Display title of the roadmap (required) */
|
||||
title: string;
|
||||
/** Optional roadmap description */
|
||||
description?: string;
|
||||
}
|
||||
/** Input for updating roadmap metadata. Ordering is handled by dedicated move/reorder DTOs. */
|
||||
export interface RoadmapUpdateInput {
|
||||
/** Updated display title */
|
||||
title?: string;
|
||||
/** Updated roadmap description */
|
||||
description?: string;
|
||||
}
|
||||
/** Input for creating a milestone inside a roadmap. */
|
||||
export interface RoadmapMilestoneCreateInput {
|
||||
/** Display title of the milestone (required) */
|
||||
title: string;
|
||||
/** Optional milestone description */
|
||||
description?: string;
|
||||
}
|
||||
/** Input for updating milestone metadata. Ordering is handled separately. */
|
||||
export interface RoadmapMilestoneUpdateInput {
|
||||
/** Updated milestone title */
|
||||
title?: string;
|
||||
/** Updated milestone description */
|
||||
description?: string;
|
||||
}
|
||||
/** Input for creating a feature inside a milestone. */
|
||||
export interface RoadmapFeatureCreateInput {
|
||||
/** Display title of the feature (required) */
|
||||
title: string;
|
||||
/** Optional feature description */
|
||||
description?: string;
|
||||
}
|
||||
/** Input for updating feature metadata. Ordering is handled separately. */
|
||||
export interface RoadmapFeatureUpdateInput {
|
||||
/** Updated feature title */
|
||||
title?: string;
|
||||
/** Updated feature description */
|
||||
description?: string;
|
||||
}
|
||||
/**
|
||||
* Explicit reorder payload for milestones within a roadmap.
|
||||
*
|
||||
* `orderedMilestoneIds` must contain the full set of milestone IDs for the
|
||||
* roadmap exactly once. Consumers should reject partial or duplicate lists.
|
||||
*/
|
||||
export interface RoadmapMilestoneReorderInput {
|
||||
/** Roadmap whose milestone sequence is being rewritten */
|
||||
roadmapId: string;
|
||||
/** Complete milestone ID sequence in final order */
|
||||
orderedMilestoneIds: string[];
|
||||
}
|
||||
/**
|
||||
* Explicit reorder payload for features within a single milestone.
|
||||
*
|
||||
* `orderedFeatureIds` must contain the full set of feature IDs for the milestone
|
||||
* exactly once. The resulting `orderIndex` values must be normalized to 0-based
|
||||
* contiguous order.
|
||||
*/
|
||||
export interface RoadmapFeatureReorderInput {
|
||||
/** Parent roadmap for integrity validation */
|
||||
roadmapId: string;
|
||||
/** Milestone whose internal feature ordering is being rewritten */
|
||||
milestoneId: string;
|
||||
/** Complete feature ID sequence in final order */
|
||||
orderedFeatureIds: string[];
|
||||
}
|
||||
/**
|
||||
* Explicit move payload for relocating a feature, including cross-milestone moves.
|
||||
*
|
||||
* `targetOrderIndex` is the desired insertion position in the destination
|
||||
* milestone before final normalization. Consumers should clamp out-of-range
|
||||
* values and must deterministically renumber both source and destination
|
||||
* milestones after the move.
|
||||
*/
|
||||
export interface RoadmapFeatureMoveInput {
|
||||
/** Parent roadmap for integrity validation */
|
||||
roadmapId: string;
|
||||
/** Feature being moved */
|
||||
featureId: string;
|
||||
/** Current milestone that owns the feature */
|
||||
fromMilestoneId: string;
|
||||
/** Destination milestone after the move */
|
||||
toMilestoneId: string;
|
||||
/** Requested insertion index in the destination milestone */
|
||||
targetOrderIndex: number;
|
||||
}
|
||||
/**
|
||||
* Result of a feature move operation after deterministic renumbering.
|
||||
*
|
||||
* `affectedFeatures` contains the canonical post-move feature records for the
|
||||
* source and target milestones. When a feature is moved within the same
|
||||
* milestone, `sourceMilestoneFeatures` and `targetMilestoneFeatures` will be
|
||||
* the same normalized list.
|
||||
*/
|
||||
export interface RoadmapFeatureMoveResult {
|
||||
/** The moved feature after `milestoneId` and `orderIndex` updates */
|
||||
movedFeature: RoadmapFeature;
|
||||
/** Canonical post-move features for the affected milestone scope */
|
||||
affectedFeatures: RoadmapFeature[];
|
||||
/** Canonical feature list for the source milestone after the move */
|
||||
sourceMilestoneFeatures: RoadmapFeature[];
|
||||
/** Canonical feature list for the destination milestone after the move */
|
||||
targetMilestoneFeatures: RoadmapFeature[];
|
||||
}
|
||||
/** Milestone with all of its ordered features loaded. */
|
||||
export interface RoadmapMilestoneWithFeatures extends RoadmapMilestone {
|
||||
/** Features belonging to this milestone */
|
||||
features: RoadmapFeature[];
|
||||
}
|
||||
/** Full roadmap hierarchy loaded in roadmap → milestone → feature order. */
|
||||
export interface RoadmapWithHierarchy extends Roadmap {
|
||||
/** Ordered milestones with ordered features */
|
||||
milestones: RoadmapMilestoneWithFeatures[];
|
||||
}
|
||||
/**
|
||||
* Flat export payload for persistence, APIs, import/export, and sync jobs.
|
||||
*
|
||||
* This shape intentionally keeps entities separate so downstream persistence
|
||||
* layers can upsert by table/collection without first denormalizing a nested
|
||||
* hierarchy.
|
||||
*/
|
||||
export interface RoadmapExportBundle {
|
||||
/** Roadmap being exported */
|
||||
roadmap: Roadmap;
|
||||
/** Ordered milestones for the roadmap */
|
||||
milestones: RoadmapMilestone[];
|
||||
/** Ordered features for the roadmap's milestones */
|
||||
features: RoadmapFeature[];
|
||||
}
|
||||
/**
|
||||
* Source metadata carried forward when a roadmap feature is converted into a
|
||||
* task-planning input or other downstream artifact.
|
||||
*/
|
||||
export interface RoadmapFeatureSourceRef {
|
||||
/** Source roadmap ID */
|
||||
roadmapId: string;
|
||||
/** Source milestone ID */
|
||||
milestoneId: string;
|
||||
/** Source feature ID */
|
||||
featureId: string;
|
||||
/** Human-readable roadmap title for prompt context */
|
||||
roadmapTitle: string;
|
||||
/** Human-readable milestone title for prompt context */
|
||||
milestoneTitle: string;
|
||||
/** Canonical milestone order at handoff time */
|
||||
milestoneOrderIndex: number;
|
||||
/** Canonical feature order at handoff time */
|
||||
featureOrderIndex: number;
|
||||
}
|
||||
/**
|
||||
* Handoff payload for converting a single roadmap feature into task planning
|
||||
* flows without coupling the task system to roadmap persistence details.
|
||||
*/
|
||||
export interface RoadmapFeatureTaskPlanningHandoff {
|
||||
/** Source lineage and ordering context */
|
||||
source: RoadmapFeatureSourceRef;
|
||||
/** Title to seed the downstream task or planning prompt */
|
||||
title: string;
|
||||
/** Optional description to seed the downstream task or planning prompt */
|
||||
description?: string;
|
||||
}
|
||||
/** Source-preserving milestone payload used for mission conversion handoffs. */
|
||||
export interface RoadmapMissionPlanningMilestoneHandoff {
|
||||
/** Source roadmap milestone ID */
|
||||
sourceMilestoneId: string;
|
||||
/** Canonical milestone title */
|
||||
title: string;
|
||||
/** Optional milestone description */
|
||||
description?: string;
|
||||
/** Canonical milestone ordering within the roadmap */
|
||||
orderIndex: number;
|
||||
/** Ordered roadmap features that belong to this milestone */
|
||||
features: Array<{
|
||||
/** Source roadmap feature ID */
|
||||
sourceFeatureId: string;
|
||||
/** Canonical feature title */
|
||||
title: string;
|
||||
/** Optional feature description */
|
||||
description?: string;
|
||||
/** Canonical feature ordering within the milestone */
|
||||
orderIndex: number;
|
||||
}>;
|
||||
}
|
||||
/**
|
||||
* Handoff payload for converting a standalone roadmap into mission planning
|
||||
* structures while preserving source IDs and deterministic order.
|
||||
*/
|
||||
export interface RoadmapMissionPlanningHandoff {
|
||||
/** Source roadmap ID */
|
||||
sourceRoadmapId: string;
|
||||
/** Canonical roadmap title */
|
||||
title: string;
|
||||
/** Optional roadmap description */
|
||||
description?: string;
|
||||
/** Ordered milestone breakdown captured at handoff time */
|
||||
milestones: RoadmapMissionPlanningMilestoneHandoff[];
|
||||
}
|
||||
//# sourceMappingURL=roadmap-types.d.ts.map
|
||||
1
plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map
Normal file
1
plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"roadmap-types.d.ts","sourceRoot":"","sources":["roadmap-types.ts"],"names":[],"mappings":"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"}
|
||||
21
plugins/fusion-plugin-roadmap/src/roadmap-types.js
Normal file
21
plugins/fusion-plugin-roadmap/src/roadmap-types.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Standalone roadmap planning types.
|
||||
*
|
||||
* This model is intentionally separate from the mission hierarchy so roadmap
|
||||
* work can evolve independently of `MissionStore`/`MissionManager`.
|
||||
*
|
||||
* Core ordering invariants:
|
||||
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
|
||||
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
|
||||
* - cross-milestone feature moves must renumber both the source and target
|
||||
* milestone deterministically after the move
|
||||
* - whenever stored order data is incomplete or conflicting, consumers should
|
||||
* repair it using a stable tie-breaker (`createdAt`, then `id`, both ASC)
|
||||
*
|
||||
* These contracts are persistence-agnostic and UI-agnostic. They define the
|
||||
* canonical domain surface that downstream storage, API, and dashboard work use.
|
||||
*
|
||||
* @module roadmap-types
|
||||
*/
|
||||
export {};
|
||||
//# sourceMappingURL=roadmap-types.js.map
|
||||
1
plugins/fusion-plugin-roadmap/src/roadmap-types.js.map
Normal file
1
plugins/fusion-plugin-roadmap/src/roadmap-types.js.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"roadmap-types.js","sourceRoot":"","sources":["roadmap-types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG"}
|
||||
4
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts
vendored
Normal file
4
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { PluginRouteDefinition } from "@fusion/core";
|
||||
export declare function createRoadmapPluginRoutes(): PluginRouteDefinition[];
|
||||
export { SUGGESTION_TIMEOUT_MS };
|
||||
//# sourceMappingURL=roadmap-routes.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"roadmap-routes.d.ts","sourceRoot":"","sources":["roadmap-routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,qBAAqB,EAAuB,MAAM,cAAc,CAAC;AAiG9F,wBAAgB,yBAAyB,IAAI,qBAAqB,EAAE,CAyRnE;AAED,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
|
||||
361
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js
Normal file
361
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js
Normal file
@@ -0,0 +1,361 @@
|
||||
import { RoadmapStore } from "../store/roadmap-store.js";
|
||||
import { generateFeatureSuggestions, generateMilestoneSuggestions, ParseError as SuggestionParseError, ServiceUnavailableError as SuggestionServiceUnavailableError, validateFeatureSuggestionInput, validateSuggestionInput, ValidationError as SuggestionValidationError, } from "./roadmap-suggestions.js";
|
||||
const roadmapStoreCache = new WeakMap();
|
||||
function getRoadmapStore(ctx) {
|
||||
const taskStoreWithRoadmaps = ctx.taskStore;
|
||||
if (typeof taskStoreWithRoadmaps.getRoadmapStore === "function") {
|
||||
return taskStoreWithRoadmaps.getRoadmapStore();
|
||||
}
|
||||
const key = ctx.taskStore;
|
||||
const cached = roadmapStoreCache.get(key);
|
||||
if (cached)
|
||||
return cached;
|
||||
const store = new RoadmapStore(ctx.taskStore.getDatabase());
|
||||
roadmapStoreCache.set(key, store);
|
||||
return store;
|
||||
}
|
||||
function asRequest(req) {
|
||||
return req;
|
||||
}
|
||||
function badRequest(message) {
|
||||
return { status: 400, body: { error: message } };
|
||||
}
|
||||
function notFound(message) {
|
||||
return { status: 404, body: { error: message } };
|
||||
}
|
||||
function serverError(message) {
|
||||
return { status: 500, body: { error: message } };
|
||||
}
|
||||
function noContent() {
|
||||
return { status: 204 };
|
||||
}
|
||||
function routeHandler(handler) {
|
||||
return async (req, ctx) => {
|
||||
const roadmapStore = getRoadmapStore(ctx);
|
||||
try {
|
||||
return await handler(asRequest(req), ctx, roadmapStore);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error && error.message.toLowerCase().includes("not found")) {
|
||||
return notFound(error.message);
|
||||
}
|
||||
return serverError(error instanceof Error ? error.message : "Internal server error");
|
||||
}
|
||||
};
|
||||
}
|
||||
function validateTitle(title) {
|
||||
if (!title || typeof title !== "string" || !title.trim()) {
|
||||
throw new Error("title is required");
|
||||
}
|
||||
if (title.length > 200) {
|
||||
throw new Error("title must not exceed 200 characters");
|
||||
}
|
||||
return title.trim();
|
||||
}
|
||||
function validateDescription(desc) {
|
||||
if (desc === undefined || desc === null)
|
||||
return undefined;
|
||||
if (typeof desc !== "string") {
|
||||
throw new Error("description must be a string");
|
||||
}
|
||||
if (desc.length > 5000) {
|
||||
throw new Error("description must not exceed 5000 characters");
|
||||
}
|
||||
return desc.trim() || undefined;
|
||||
}
|
||||
function validateStringArray(arr, fieldName) {
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new Error(`${fieldName} must be an array`);
|
||||
}
|
||||
if (!arr.every((item) => typeof item === "string")) {
|
||||
throw new Error(`${fieldName} must be an array of strings`);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
export function createRoadmapPluginRoutes() {
|
||||
return [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps",
|
||||
handler: routeHandler((_req, _ctx, roadmapStore) => roadmapStore.listRoadmaps()),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
try {
|
||||
return {
|
||||
status: 201,
|
||||
body: roadmapStore.createRoadmap({
|
||||
title: validateTitle(body?.title),
|
||||
description: validateDescription(body?.description),
|
||||
}),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const roadmap = roadmapStore.getRoadmapWithHierarchy(req.params.roadmapId);
|
||||
return roadmap ? roadmap : notFound(`Roadmap ${req.params.roadmapId} not found`);
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/roadmaps/:roadmapId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
try {
|
||||
return roadmapStore.updateRoadmap(req.params.roadmapId, {
|
||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{ method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
roadmapStore.deleteRoadmap(req.params.roadmapId);
|
||||
return noContent();
|
||||
}) },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/:roadmapId/milestones",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
try {
|
||||
return {
|
||||
status: 201,
|
||||
body: roadmapStore.createMilestone(req.params.roadmapId, {
|
||||
title: validateTitle(body?.title),
|
||||
description: validateDescription(body?.description),
|
||||
}),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/:roadmapId/milestones/reorder",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
roadmapStore.reorderMilestones({ roadmapId: req.params.roadmapId, orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") });
|
||||
return noContent();
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/roadmaps/milestones/:milestoneId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
try {
|
||||
return roadmapStore.updateMilestone(req.params.milestoneId, {
|
||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{ method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
roadmapStore.deleteMilestone(req.params.milestoneId);
|
||||
return noContent();
|
||||
}) },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/milestones/:milestoneId/features",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
try {
|
||||
return {
|
||||
status: 201,
|
||||
body: roadmapStore.createFeature(req.params.milestoneId, {
|
||||
title: validateTitle(body?.title),
|
||||
description: validateDescription(body?.description),
|
||||
}),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/milestones/:milestoneId/features/reorder",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
||||
if (!milestone)
|
||||
return notFound(`Milestone ${req.params.milestoneId} not found`);
|
||||
roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: req.params.milestoneId, orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") });
|
||||
return noContent();
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/roadmaps/features/:featureId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
try {
|
||||
return roadmapStore.updateFeature(req.params.featureId, {
|
||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{ method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
roadmapStore.deleteFeature(req.params.featureId);
|
||||
return noContent();
|
||||
}) },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/features/:featureId/move",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
if (!body?.targetMilestoneId)
|
||||
return badRequest("targetMilestoneId is required");
|
||||
if (typeof body.targetIndex !== "number")
|
||||
return badRequest("targetIndex must be a number");
|
||||
const feature = roadmapStore.getFeature(req.params.featureId);
|
||||
if (!feature)
|
||||
return notFound(`Feature ${req.params.featureId} not found`);
|
||||
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
|
||||
if (!fromMilestone)
|
||||
return notFound(`Source milestone ${feature.milestoneId} not found`);
|
||||
const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId);
|
||||
if (!toMilestone)
|
||||
return notFound(`Target milestone ${body.targetMilestoneId} not found`);
|
||||
roadmapStore.moveFeature({
|
||||
roadmapId: fromMilestone.roadmapId,
|
||||
featureId: req.params.featureId,
|
||||
fromMilestoneId: feature.milestoneId,
|
||||
toMilestoneId: body.targetMilestoneId,
|
||||
targetOrderIndex: body.targetIndex,
|
||||
});
|
||||
return noContent();
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/:roadmapId/suggestions/milestones",
|
||||
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||
const roadmap = roadmapStore.getRoadmap(req.params.roadmapId);
|
||||
if (!roadmap)
|
||||
return notFound(`Roadmap ${req.params.roadmapId} not found`);
|
||||
try {
|
||||
validateSuggestionInput(req.body);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SuggestionValidationError)
|
||||
return badRequest(error.message);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const body = req.body;
|
||||
const suggestions = await generateMilestoneSuggestions(body.goalPrompt, body.count, ctx.taskStore.getRootDir(), undefined, undefined, ctx.createAiSession);
|
||||
return { suggestions };
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SuggestionParseError)
|
||||
return serverError(error.message);
|
||||
if (error instanceof SuggestionServiceUnavailableError) {
|
||||
return { status: 503, body: { error: error.message } };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/milestones/:milestoneId/suggestions/features",
|
||||
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
||||
if (!milestone)
|
||||
return notFound(`Milestone ${req.params.milestoneId} not found`);
|
||||
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
|
||||
if (!roadmap)
|
||||
return notFound(`Roadmap ${milestone.roadmapId} not found`);
|
||||
try {
|
||||
validateFeatureSuggestionInput(req.body);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SuggestionValidationError)
|
||||
return badRequest(error.message);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const body = req.body;
|
||||
const suggestions = await generateFeatureSuggestions({
|
||||
roadmapTitle: roadmap.title,
|
||||
roadmapDescription: roadmap.description,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneDescription: milestone.description,
|
||||
existingFeatureTitles: roadmapStore.listFeatures(milestone.id).map((feature) => feature.title),
|
||||
}, body.count, body.prompt, ctx.taskStore.getRootDir(), undefined, undefined, ctx.createAiSession);
|
||||
return { suggestions };
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SuggestionParseError)
|
||||
return serverError(error.message);
|
||||
if (error instanceof SuggestionServiceUnavailableError) {
|
||||
return { status: 503, body: { error: error.message } };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/export",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapExport(req.params.roadmapId)),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/handoff",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => ({
|
||||
mission: roadmapStore.getMissionPlanningHandoff(req.params.roadmapId),
|
||||
features: roadmapStore.listFeatureTaskPlanningHandoffs(req.params.roadmapId),
|
||||
})),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/handoff/mission",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getMissionPlanningHandoff(req.params.roadmapId)),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapFeatureHandoff(req.params.roadmapId, req.params.milestoneId, req.params.featureId)),
|
||||
},
|
||||
];
|
||||
}
|
||||
export { SUGGESTION_TIMEOUT_MS } from "./roadmap-suggestions.js";
|
||||
//# sourceMappingURL=roadmap-routes.js.map
|
||||
File diff suppressed because one or more lines are too long
381
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts
Normal file
381
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
|
||||
import type { Request } from "express";
|
||||
import { RoadmapStore } from "../store/roadmap-store.js";
|
||||
import {
|
||||
generateFeatureSuggestions,
|
||||
generateMilestoneSuggestions,
|
||||
ParseError as SuggestionParseError,
|
||||
ServiceUnavailableError as SuggestionServiceUnavailableError,
|
||||
validateFeatureSuggestionInput,
|
||||
validateSuggestionInput,
|
||||
ValidationError as SuggestionValidationError,
|
||||
} from "./roadmap-suggestions.js";
|
||||
|
||||
const roadmapStoreCache = new WeakMap<object, RoadmapStore>();
|
||||
|
||||
function getRoadmapStore(ctx: PluginContext): RoadmapStore {
|
||||
const taskStoreWithRoadmaps = ctx.taskStore as PluginContext["taskStore"] & {
|
||||
getRoadmapStore?: () => RoadmapStore;
|
||||
};
|
||||
|
||||
if (typeof taskStoreWithRoadmaps.getRoadmapStore === "function") {
|
||||
return taskStoreWithRoadmaps.getRoadmapStore();
|
||||
}
|
||||
|
||||
const key = ctx.taskStore as object;
|
||||
const cached = roadmapStoreCache.get(key);
|
||||
if (cached) return cached;
|
||||
const store = new RoadmapStore(ctx.taskStore.getDatabase());
|
||||
roadmapStoreCache.set(key, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
function asRequest(req: unknown): Request {
|
||||
return req as Request;
|
||||
}
|
||||
|
||||
function badRequest(message: string): PluginRouteResponse {
|
||||
return { status: 400, body: { error: message } };
|
||||
}
|
||||
|
||||
function notFound(message: string): PluginRouteResponse {
|
||||
return { status: 404, body: { error: message } };
|
||||
}
|
||||
|
||||
function serverError(message: string): PluginRouteResponse {
|
||||
return { status: 500, body: { error: message } };
|
||||
}
|
||||
|
||||
function noContent(): PluginRouteResponse {
|
||||
return { status: 204 };
|
||||
}
|
||||
|
||||
function routeHandler<T>(handler: (req: Request, ctx: PluginContext, roadmapStore: RoadmapStore) => Promise<T | PluginRouteResponse> | T | PluginRouteResponse) {
|
||||
return async (req: unknown, ctx: PluginContext): Promise<T | PluginRouteResponse> => {
|
||||
const roadmapStore = getRoadmapStore(ctx);
|
||||
try {
|
||||
return await handler(asRequest(req), ctx, roadmapStore);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.toLowerCase().includes("not found")) {
|
||||
return notFound(error.message);
|
||||
}
|
||||
return serverError(error instanceof Error ? error.message : "Internal server error");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function validateTitle(title: unknown): string {
|
||||
if (!title || typeof title !== "string" || !title.trim()) {
|
||||
throw new Error("title is required");
|
||||
}
|
||||
if (title.length > 200) {
|
||||
throw new Error("title must not exceed 200 characters");
|
||||
}
|
||||
return title.trim();
|
||||
}
|
||||
|
||||
function validateDescription(desc: unknown): string | undefined {
|
||||
if (desc === undefined || desc === null) return undefined;
|
||||
if (typeof desc !== "string") {
|
||||
throw new Error("description must be a string");
|
||||
}
|
||||
if (desc.length > 5000) {
|
||||
throw new Error("description must not exceed 5000 characters");
|
||||
}
|
||||
return desc.trim() || undefined;
|
||||
}
|
||||
|
||||
function validateStringArray(arr: unknown, fieldName: string): string[] {
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new Error(`${fieldName} must be an array`);
|
||||
}
|
||||
if (!arr.every((item) => typeof item === "string")) {
|
||||
throw new Error(`${fieldName} must be an array of strings`);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
||||
return [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps",
|
||||
handler: routeHandler((_req, _ctx, roadmapStore) => roadmapStore.listRoadmaps()),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { title: string; description?: string };
|
||||
try {
|
||||
return {
|
||||
status: 201,
|
||||
body: roadmapStore.createRoadmap({
|
||||
title: validateTitle(body?.title),
|
||||
description: validateDescription(body?.description),
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const roadmap = roadmapStore.getRoadmapWithHierarchy(req.params.roadmapId);
|
||||
return roadmap ? roadmap : notFound(`Roadmap ${req.params.roadmapId} not found`);
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/roadmaps/:roadmapId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { title?: string; description?: string };
|
||||
try {
|
||||
return roadmapStore.updateRoadmap(req.params.roadmapId, {
|
||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{ method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
roadmapStore.deleteRoadmap(req.params.roadmapId);
|
||||
return noContent();
|
||||
}) },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/:roadmapId/milestones",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { title: string; description?: string };
|
||||
try {
|
||||
return {
|
||||
status: 201,
|
||||
body: roadmapStore.createMilestone(req.params.roadmapId, {
|
||||
title: validateTitle(body?.title),
|
||||
description: validateDescription(body?.description),
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/:roadmapId/milestones/reorder",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
try {
|
||||
const body = req.body as { orderedMilestoneIds: string[] };
|
||||
roadmapStore.reorderMilestones({ roadmapId: req.params.roadmapId, orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") });
|
||||
return noContent();
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/roadmaps/milestones/:milestoneId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { title?: string; description?: string };
|
||||
try {
|
||||
return roadmapStore.updateMilestone(req.params.milestoneId, {
|
||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{ method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
roadmapStore.deleteMilestone(req.params.milestoneId);
|
||||
return noContent();
|
||||
}) },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/milestones/:milestoneId/features",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { title: string; description?: string };
|
||||
try {
|
||||
return {
|
||||
status: 201,
|
||||
body: roadmapStore.createFeature(req.params.milestoneId, {
|
||||
title: validateTitle(body?.title),
|
||||
description: validateDescription(body?.description),
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/milestones/:milestoneId/features/reorder",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
try {
|
||||
const body = req.body as { orderedFeatureIds: string[] };
|
||||
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
||||
if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`);
|
||||
roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: req.params.milestoneId, orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") });
|
||||
return noContent();
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/roadmaps/features/:featureId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { title?: string; description?: string };
|
||||
try {
|
||||
return roadmapStore.updateFeature(req.params.featureId, {
|
||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{ method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
roadmapStore.deleteFeature(req.params.featureId);
|
||||
return noContent();
|
||||
}) },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/features/:featureId/move",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { targetMilestoneId: string; targetIndex: number };
|
||||
if (!body?.targetMilestoneId) return badRequest("targetMilestoneId is required");
|
||||
if (typeof body.targetIndex !== "number") return badRequest("targetIndex must be a number");
|
||||
|
||||
const feature = roadmapStore.getFeature(req.params.featureId);
|
||||
if (!feature) return notFound(`Feature ${req.params.featureId} not found`);
|
||||
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
|
||||
if (!fromMilestone) return notFound(`Source milestone ${feature.milestoneId} not found`);
|
||||
const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId);
|
||||
if (!toMilestone) return notFound(`Target milestone ${body.targetMilestoneId} not found`);
|
||||
|
||||
roadmapStore.moveFeature({
|
||||
roadmapId: fromMilestone.roadmapId,
|
||||
featureId: req.params.featureId,
|
||||
fromMilestoneId: feature.milestoneId,
|
||||
toMilestoneId: body.targetMilestoneId,
|
||||
targetOrderIndex: body.targetIndex,
|
||||
});
|
||||
|
||||
return noContent();
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/:roadmapId/suggestions/milestones",
|
||||
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||
const roadmap = roadmapStore.getRoadmap(req.params.roadmapId);
|
||||
if (!roadmap) return notFound(`Roadmap ${req.params.roadmapId} not found`);
|
||||
|
||||
try {
|
||||
validateSuggestionInput(req.body);
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionValidationError) return badRequest(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = req.body as { goalPrompt: string; count?: number };
|
||||
const suggestions = await generateMilestoneSuggestions(
|
||||
body.goalPrompt,
|
||||
body.count,
|
||||
ctx.taskStore.getRootDir(),
|
||||
undefined,
|
||||
undefined,
|
||||
ctx.createAiSession,
|
||||
);
|
||||
return { suggestions };
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionParseError) return serverError(error.message);
|
||||
if (error instanceof SuggestionServiceUnavailableError) {
|
||||
return { status: 503, body: { error: error.message } };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/milestones/:milestoneId/suggestions/features",
|
||||
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
||||
if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`);
|
||||
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
|
||||
if (!roadmap) return notFound(`Roadmap ${milestone.roadmapId} not found`);
|
||||
|
||||
try {
|
||||
validateFeatureSuggestionInput(req.body);
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionValidationError) return badRequest(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = req.body as { prompt?: string; count?: number };
|
||||
const suggestions = await generateFeatureSuggestions(
|
||||
{
|
||||
roadmapTitle: roadmap.title,
|
||||
roadmapDescription: roadmap.description,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneDescription: milestone.description,
|
||||
existingFeatureTitles: roadmapStore.listFeatures(milestone.id).map((feature) => feature.title),
|
||||
},
|
||||
body.count,
|
||||
body.prompt,
|
||||
ctx.taskStore.getRootDir(),
|
||||
undefined,
|
||||
undefined,
|
||||
ctx.createAiSession,
|
||||
);
|
||||
return { suggestions };
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionParseError) return serverError(error.message);
|
||||
if (error instanceof SuggestionServiceUnavailableError) {
|
||||
return { status: 503, body: { error: error.message } };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/export",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapExport(req.params.roadmapId)),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/handoff",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => ({
|
||||
mission: roadmapStore.getMissionPlanningHandoff(req.params.roadmapId),
|
||||
features: roadmapStore.listFeatureTaskPlanningHandoffs(req.params.roadmapId),
|
||||
})),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/handoff/mission",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getMissionPlanningHandoff(req.params.roadmapId)),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapFeatureHandoff(req.params.roadmapId, req.params.milestoneId, req.params.featureId)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export { SUGGESTION_TIMEOUT_MS };
|
||||
68
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts
vendored
Normal file
68
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { CreateAiSessionFactory } from "@fusion/core";
|
||||
type LegacyCreateFnAgent = (options: {
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
tools?: "coding" | "readonly";
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
onThinking?: () => void;
|
||||
onText?: () => void;
|
||||
}) => Promise<{
|
||||
session: {
|
||||
prompt(text: string): Promise<void>;
|
||||
state: {
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content?: string | Array<{
|
||||
type: string;
|
||||
text: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
dispose?: () => void;
|
||||
};
|
||||
}>;
|
||||
export interface GenerateMilestoneSuggestionsInput {
|
||||
goalPrompt: string;
|
||||
count?: number;
|
||||
}
|
||||
export interface MilestoneSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
export declare const MILESTONE_SUGGESTION_SYSTEM_PROMPT = "You are a milestone planning assistant for a product roadmap system.\n\nYour job is to suggest logical milestones that would help achieve a user's roadmap goal.\n\n## Guidelines\n\n1. **Think about phases**: Break the goal into logical phases\n2. **Use clear titles**: Milestone titles should be concise and descriptive\n3. **Add context**: Include a brief description explaining what this milestone encompasses\n4. **Order matters**: List milestones in the order they should be completed\n5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks\n\n## Output Format\n\nRespond with ONLY a valid JSON array of milestone suggestions.";
|
||||
export declare const SUGGESTION_TIMEOUT_MS = 120000;
|
||||
export declare function validateSuggestionInput(input: unknown): asserts input is GenerateMilestoneSuggestionsInput;
|
||||
export declare function generateMilestoneSuggestions(goalPrompt: string, count?: number, rootDir?: string, modelProvider?: string, modelId?: string, createAiSession?: CreateAiSessionFactory): Promise<MilestoneSuggestion[]>;
|
||||
export interface GenerateFeatureSuggestionsInput {
|
||||
prompt?: string;
|
||||
count?: number;
|
||||
}
|
||||
export interface FeatureSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
export interface FeatureSuggestionContext {
|
||||
roadmapTitle: string;
|
||||
roadmapDescription?: string;
|
||||
milestoneTitle: string;
|
||||
milestoneDescription?: string;
|
||||
existingFeatureTitles: string[];
|
||||
}
|
||||
export declare const FEATURE_SUGGESTION_SYSTEM_PROMPT = "You are a feature planning assistant for a product roadmap system.";
|
||||
export declare function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput;
|
||||
export declare function generateFeatureSuggestions(context: FeatureSuggestionContext, count?: number, prompt?: string, rootDir?: string, modelProvider?: string, modelId?: string, createAiSession?: CreateAiSessionFactory): Promise<FeatureSuggestion[]>;
|
||||
export declare class ValidationError extends Error {
|
||||
constructor(message: string);
|
||||
}
|
||||
export declare class ParseError extends Error {
|
||||
constructor(message: string);
|
||||
}
|
||||
export declare class ServiceUnavailableError extends Error {
|
||||
constructor(message: string);
|
||||
}
|
||||
export declare function __resetSuggestionState(): void;
|
||||
export declare function __setCreateFnAgent(mock: LegacyCreateFnAgent | undefined): void;
|
||||
export declare function __setCreateAiSessionFactory(mock: CreateAiSessionFactory | undefined): void;
|
||||
export {};
|
||||
//# sourceMappingURL=roadmap-suggestions.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"roadmap-suggestions.d.ts","sourceRoot":"","sources":["roadmap-suggestions.ts"],"names":[],"mappings":"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"}
|
||||
300
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js
Normal file
300
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js
Normal file
@@ -0,0 +1,300 @@
|
||||
let injectedCreateAiSession;
|
||||
export const MILESTONE_SUGGESTION_SYSTEM_PROMPT = `You are a milestone planning assistant for a product roadmap system.
|
||||
|
||||
Your job is to suggest logical milestones that would help achieve a user's roadmap goal.
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. **Think about phases**: Break the goal into logical phases
|
||||
2. **Use clear titles**: Milestone titles should be concise and descriptive
|
||||
3. **Add context**: Include a brief description explaining what this milestone encompasses
|
||||
4. **Order matters**: List milestones in the order they should be completed
|
||||
5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond with ONLY a valid JSON array of milestone suggestions.`;
|
||||
const MAX_GOAL_PROMPT_LENGTH = 4000;
|
||||
export const SUGGESTION_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_SUGGESTION_COUNT = 5;
|
||||
const MAX_SUGGESTION_COUNT = 10;
|
||||
const MIN_SUGGESTION_COUNT = 1;
|
||||
const MAX_PARSE_RETRIES = 1;
|
||||
export function validateSuggestionInput(input) {
|
||||
if (!input || typeof input !== "object") {
|
||||
throw new ValidationError("Request body must be an object");
|
||||
}
|
||||
const { goalPrompt, count } = input;
|
||||
if (typeof goalPrompt !== "string" || !goalPrompt.trim()) {
|
||||
throw new ValidationError("goalPrompt is required and must be a non-empty string");
|
||||
}
|
||||
if (goalPrompt.length > MAX_GOAL_PROMPT_LENGTH) {
|
||||
throw new ValidationError(`goalPrompt exceeds maximum length of ${MAX_GOAL_PROMPT_LENGTH} characters`);
|
||||
}
|
||||
if (count !== undefined) {
|
||||
if (typeof count !== "number" || !Number.isInteger(count))
|
||||
throw new ValidationError("count must be an integer");
|
||||
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||
throw new ValidationError(`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function extractJsonCandidate(text) {
|
||||
if (!text || !text.trim())
|
||||
return null;
|
||||
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||||
const source = codeBlockMatch?.[1]?.trim() || text.trim();
|
||||
const startIndex = source.indexOf("[");
|
||||
if (startIndex < 0)
|
||||
return null;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let index = startIndex; index < source.length; index++) {
|
||||
const char = source[index];
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
}
|
||||
else if (char === "\\") {
|
||||
escaped = true;
|
||||
}
|
||||
else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "[")
|
||||
depth++;
|
||||
if (char === "]") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return source.slice(startIndex, index + 1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return source.slice(startIndex).trim();
|
||||
}
|
||||
function repairJson(text) {
|
||||
let repaired = text.replace(/,\s*([}\]])/g, "$1");
|
||||
let depthBraces = 0;
|
||||
let depthBrackets = 0;
|
||||
for (const ch of repaired) {
|
||||
if (ch === "{")
|
||||
depthBraces++;
|
||||
if (ch === "}")
|
||||
depthBraces--;
|
||||
if (ch === "[")
|
||||
depthBrackets++;
|
||||
if (ch === "]")
|
||||
depthBrackets--;
|
||||
}
|
||||
repaired += "]".repeat(Math.max(0, depthBrackets));
|
||||
repaired += "}".repeat(Math.max(0, depthBraces));
|
||||
return repaired;
|
||||
}
|
||||
function parseMilestoneSuggestions(text) {
|
||||
const candidate = extractJsonCandidate(text);
|
||||
if (!candidate)
|
||||
throw new ParseError("AI returned no valid JSON. Please try again.");
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
}
|
||||
catch {
|
||||
parsed = JSON.parse(repairJson(candidate));
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new ParseError("AI response must be a JSON array of milestone suggestions");
|
||||
}
|
||||
const suggestions = [];
|
||||
for (const item of parsed) {
|
||||
if (!item || typeof item !== "object")
|
||||
continue;
|
||||
const row = item;
|
||||
if (typeof row.title !== "string" || !row.title.trim())
|
||||
continue;
|
||||
suggestions.push({
|
||||
title: row.title.trim(),
|
||||
description: typeof row.description === "string" && row.description.trim() ? row.description.trim() : undefined,
|
||||
});
|
||||
}
|
||||
if (suggestions.length === 0)
|
||||
throw new ParseError("AI returned no valid milestone suggestions");
|
||||
return suggestions;
|
||||
}
|
||||
function pickFactory(explicit) {
|
||||
return explicit ?? injectedCreateAiSession;
|
||||
}
|
||||
async function runPrompt(createAiSession, options, prompt) {
|
||||
const agent = await createAiSession(options);
|
||||
await agent.session.prompt(prompt);
|
||||
const lastMessage = agent.session.state.messages
|
||||
.filter((m) => m.role === "assistant")
|
||||
.pop();
|
||||
let text = "";
|
||||
if (lastMessage?.content) {
|
||||
if (typeof lastMessage.content === "string")
|
||||
text = lastMessage.content;
|
||||
else {
|
||||
text = lastMessage.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
return { text, dispose: agent.session.dispose };
|
||||
}
|
||||
export async function generateMilestoneSuggestions(goalPrompt, count = DEFAULT_SUGGESTION_COUNT, rootDir, modelProvider, modelId, createAiSession) {
|
||||
const factory = pickFactory(createAiSession);
|
||||
if (!factory)
|
||||
throw new ServiceUnavailableError("AI service is not available");
|
||||
if (!rootDir)
|
||||
throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
let dispose;
|
||||
try {
|
||||
let response = await runPrompt(factory, {
|
||||
cwd: rootDir,
|
||||
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||
}, `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`);
|
||||
dispose = response.dispose;
|
||||
let suggestions;
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||
try {
|
||||
suggestions = parseMilestoneSuggestions(response.text);
|
||||
break;
|
||||
}
|
||||
catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (attempt === MAX_PARSE_RETRIES)
|
||||
break;
|
||||
response = await runPrompt(factory, {
|
||||
cwd: rootDir,
|
||||
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||
}, "Your previous response could not be parsed as JSON. Respond with only a JSON array.");
|
||||
dispose = response.dispose;
|
||||
}
|
||||
}
|
||||
if (!suggestions) {
|
||||
throw new ParseError(`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message ?? "Unknown error"}`);
|
||||
}
|
||||
return suggestions.slice(0, count);
|
||||
}
|
||||
finally {
|
||||
dispose?.();
|
||||
}
|
||||
})(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
|
||||
]);
|
||||
return result;
|
||||
}
|
||||
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.`;
|
||||
const MAX_FEATURE_PROMPT_LENGTH = 2000;
|
||||
export function validateFeatureSuggestionInput(input) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input))
|
||||
throw new ValidationError("Request body must be an object");
|
||||
const { prompt, count } = input;
|
||||
if (prompt !== undefined) {
|
||||
if (typeof prompt !== "string")
|
||||
throw new ValidationError("prompt must be a string");
|
||||
if (prompt.length > MAX_FEATURE_PROMPT_LENGTH)
|
||||
throw new ValidationError(`prompt exceeds maximum length of ${MAX_FEATURE_PROMPT_LENGTH} characters`);
|
||||
}
|
||||
if (count !== undefined) {
|
||||
if (typeof count !== "number" || !Number.isInteger(count))
|
||||
throw new ValidationError("count must be an integer");
|
||||
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||
throw new ValidationError(`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function buildMilestoneContextString(context) {
|
||||
const lines = [];
|
||||
lines.push(`Roadmap: ${context.roadmapTitle}`);
|
||||
if (context.roadmapDescription)
|
||||
lines.push(`Description: ${context.roadmapDescription}`);
|
||||
lines.push("", `Milestone: ${context.milestoneTitle}`);
|
||||
if (context.milestoneDescription)
|
||||
lines.push(`Description: ${context.milestoneDescription}`);
|
||||
if (context.existingFeatureTitles.length > 0) {
|
||||
lines.push("", "Existing features in this milestone:");
|
||||
for (const title of context.existingFeatureTitles)
|
||||
lines.push(` - ${title}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
function parseFeatureSuggestions(text) {
|
||||
return parseMilestoneSuggestions(text);
|
||||
}
|
||||
export async function generateFeatureSuggestions(context, count = DEFAULT_SUGGESTION_COUNT, prompt, rootDir, modelProvider, modelId, createAiSession) {
|
||||
const factory = pickFactory(createAiSession);
|
||||
if (!factory)
|
||||
throw new ServiceUnavailableError("AI service is not available");
|
||||
if (!rootDir)
|
||||
throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||
const systemPrompt = `${FEATURE_SUGGESTION_SYSTEM_PROMPT}\n\n${buildMilestoneContextString(context)}`;
|
||||
const userMessage = prompt?.trim()
|
||||
? `Please suggest ${count} features for the milestone described above.\n\nAdditional guidance:\n${prompt.trim()}`
|
||||
: `Please suggest ${count} features for the milestone described above.`;
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
const { text, dispose } = await runPrompt(factory, {
|
||||
cwd: rootDir,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||
}, userMessage);
|
||||
try {
|
||||
return parseFeatureSuggestions(text).slice(0, count);
|
||||
}
|
||||
finally {
|
||||
dispose?.();
|
||||
}
|
||||
})(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
|
||||
]);
|
||||
return result;
|
||||
}
|
||||
export class ValidationError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
export class ParseError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "ParseError";
|
||||
}
|
||||
}
|
||||
export class ServiceUnavailableError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "ServiceUnavailableError";
|
||||
}
|
||||
}
|
||||
export function __resetSuggestionState() {
|
||||
injectedCreateAiSession = undefined;
|
||||
}
|
||||
export function __setCreateFnAgent(mock) {
|
||||
if (!mock) {
|
||||
injectedCreateAiSession = undefined;
|
||||
return;
|
||||
}
|
||||
injectedCreateAiSession = async (options) => mock(options);
|
||||
}
|
||||
export function __setCreateAiSessionFactory(mock) {
|
||||
injectedCreateAiSession = mock;
|
||||
}
|
||||
//# sourceMappingURL=roadmap-suggestions.js.map
|
||||
File diff suppressed because one or more lines are too long
381
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts
Normal file
381
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import type { CreateAiSessionFactory } from "@fusion/core";
|
||||
|
||||
type LegacyCreateFnAgent = (options: {
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
tools?: "coding" | "readonly";
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
onThinking?: () => void;
|
||||
onText?: () => void;
|
||||
}) => Promise<{
|
||||
session: {
|
||||
prompt(text: string): Promise<void>;
|
||||
state: { messages: Array<{ role: string; content?: string | Array<{ type: string; text: string }> }> };
|
||||
dispose?: () => void;
|
||||
};
|
||||
}>;
|
||||
|
||||
let injectedCreateAiSession: CreateAiSessionFactory | undefined;
|
||||
|
||||
export interface GenerateMilestoneSuggestionsInput {
|
||||
goalPrompt: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface MilestoneSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const MILESTONE_SUGGESTION_SYSTEM_PROMPT = `You are a milestone planning assistant for a product roadmap system.
|
||||
|
||||
Your job is to suggest logical milestones that would help achieve a user's roadmap goal.
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. **Think about phases**: Break the goal into logical phases
|
||||
2. **Use clear titles**: Milestone titles should be concise and descriptive
|
||||
3. **Add context**: Include a brief description explaining what this milestone encompasses
|
||||
4. **Order matters**: List milestones in the order they should be completed
|
||||
5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond with ONLY a valid JSON array of milestone suggestions.`;
|
||||
|
||||
const MAX_GOAL_PROMPT_LENGTH = 4000;
|
||||
export const SUGGESTION_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_SUGGESTION_COUNT = 5;
|
||||
const MAX_SUGGESTION_COUNT = 10;
|
||||
const MIN_SUGGESTION_COUNT = 1;
|
||||
const MAX_PARSE_RETRIES = 1;
|
||||
|
||||
export function validateSuggestionInput(input: unknown): asserts input is GenerateMilestoneSuggestionsInput {
|
||||
if (!input || typeof input !== "object") {
|
||||
throw new ValidationError("Request body must be an object");
|
||||
}
|
||||
const { goalPrompt, count } = input as Record<string, unknown>;
|
||||
if (typeof goalPrompt !== "string" || !goalPrompt.trim()) {
|
||||
throw new ValidationError("goalPrompt is required and must be a non-empty string");
|
||||
}
|
||||
if (goalPrompt.length > MAX_GOAL_PROMPT_LENGTH) {
|
||||
throw new ValidationError(`goalPrompt exceeds maximum length of ${MAX_GOAL_PROMPT_LENGTH} characters`);
|
||||
}
|
||||
if (count !== undefined) {
|
||||
if (typeof count !== "number" || !Number.isInteger(count)) throw new ValidationError("count must be an integer");
|
||||
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||
throw new ValidationError(`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractJsonCandidate(text: string): string | null {
|
||||
if (!text || !text.trim()) return null;
|
||||
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||||
const source = codeBlockMatch?.[1]?.trim() || text.trim();
|
||||
|
||||
const startIndex = source.indexOf("[");
|
||||
if (startIndex < 0) return null;
|
||||
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let index = startIndex; index < source.length; index++) {
|
||||
const char = source[index];
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "[") depth++;
|
||||
if (char === "]") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return source.slice(startIndex, index + 1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return source.slice(startIndex).trim();
|
||||
}
|
||||
|
||||
function repairJson(text: string): string {
|
||||
let repaired = text.replace(/,\s*([}\]])/g, "$1");
|
||||
let depthBraces = 0;
|
||||
let depthBrackets = 0;
|
||||
for (const ch of repaired) {
|
||||
if (ch === "{") depthBraces++;
|
||||
if (ch === "}") depthBraces--;
|
||||
if (ch === "[") depthBrackets++;
|
||||
if (ch === "]") depthBrackets--;
|
||||
}
|
||||
repaired += "]".repeat(Math.max(0, depthBrackets));
|
||||
repaired += "}".repeat(Math.max(0, depthBraces));
|
||||
return repaired;
|
||||
}
|
||||
|
||||
function parseMilestoneSuggestions(text: string): MilestoneSuggestion[] {
|
||||
const candidate = extractJsonCandidate(text);
|
||||
if (!candidate) throw new ParseError("AI returned no valid JSON. Please try again.");
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
} catch {
|
||||
parsed = JSON.parse(repairJson(candidate));
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new ParseError("AI response must be a JSON array of milestone suggestions");
|
||||
}
|
||||
|
||||
const suggestions: MilestoneSuggestion[] = [];
|
||||
for (const item of parsed) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
if (typeof row.title !== "string" || !row.title.trim()) continue;
|
||||
suggestions.push({
|
||||
title: row.title.trim(),
|
||||
description: typeof row.description === "string" && row.description.trim() ? row.description.trim() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) throw new ParseError("AI returned no valid milestone suggestions");
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
interface AgentMessage {
|
||||
role: string;
|
||||
content?: string | Array<{ type: string; text: string }>;
|
||||
}
|
||||
|
||||
function pickFactory(explicit?: CreateAiSessionFactory): CreateAiSessionFactory | undefined {
|
||||
return explicit ?? injectedCreateAiSession;
|
||||
}
|
||||
|
||||
async function runPrompt(
|
||||
createAiSession: CreateAiSessionFactory,
|
||||
options: Parameters<CreateAiSessionFactory>[0],
|
||||
prompt: string,
|
||||
): Promise<{ text: string; dispose?: () => void }> {
|
||||
const agent = await createAiSession(options);
|
||||
await agent.session.prompt(prompt);
|
||||
const lastMessage = (agent.session.state.messages as AgentMessage[])
|
||||
.filter((m) => m.role === "assistant")
|
||||
.pop();
|
||||
|
||||
let text = "";
|
||||
if (lastMessage?.content) {
|
||||
if (typeof lastMessage.content === "string") text = lastMessage.content;
|
||||
else {
|
||||
text = lastMessage.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
return { text, dispose: (agent.session as { dispose?: () => void }).dispose };
|
||||
}
|
||||
|
||||
export async function generateMilestoneSuggestions(
|
||||
goalPrompt: string,
|
||||
count: number = DEFAULT_SUGGESTION_COUNT,
|
||||
rootDir?: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
createAiSession?: CreateAiSessionFactory,
|
||||
): Promise<MilestoneSuggestion[]> {
|
||||
const factory = pickFactory(createAiSession);
|
||||
if (!factory) throw new ServiceUnavailableError("AI service is not available");
|
||||
if (!rootDir) throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
let dispose: (() => void) | undefined;
|
||||
try {
|
||||
let response = await runPrompt(factory, {
|
||||
cwd: rootDir,
|
||||
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||
}, `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`);
|
||||
dispose = response.dispose;
|
||||
|
||||
let suggestions: MilestoneSuggestion[] | undefined;
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||
try {
|
||||
suggestions = parseMilestoneSuggestions(response.text);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (attempt === MAX_PARSE_RETRIES) break;
|
||||
response = await runPrompt(factory, {
|
||||
cwd: rootDir,
|
||||
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||
}, "Your previous response could not be parsed as JSON. Respond with only a JSON array.");
|
||||
dispose = response.dispose;
|
||||
}
|
||||
}
|
||||
|
||||
if (!suggestions) {
|
||||
throw new ParseError(`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message ?? "Unknown error"}`);
|
||||
}
|
||||
|
||||
return suggestions.slice(0, count);
|
||||
} finally {
|
||||
dispose?.();
|
||||
}
|
||||
})(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS),
|
||||
),
|
||||
]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface GenerateFeatureSuggestionsInput {
|
||||
prompt?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface FeatureSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface FeatureSuggestionContext {
|
||||
roadmapTitle: string;
|
||||
roadmapDescription?: string;
|
||||
milestoneTitle: string;
|
||||
milestoneDescription?: string;
|
||||
existingFeatureTitles: string[];
|
||||
}
|
||||
|
||||
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.`;
|
||||
|
||||
const MAX_FEATURE_PROMPT_LENGTH = 2000;
|
||||
|
||||
export function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) throw new ValidationError("Request body must be an object");
|
||||
const { prompt, count } = input as Record<string, unknown>;
|
||||
if (prompt !== undefined) {
|
||||
if (typeof prompt !== "string") throw new ValidationError("prompt must be a string");
|
||||
if (prompt.length > MAX_FEATURE_PROMPT_LENGTH) throw new ValidationError(`prompt exceeds maximum length of ${MAX_FEATURE_PROMPT_LENGTH} characters`);
|
||||
}
|
||||
if (count !== undefined) {
|
||||
if (typeof count !== "number" || !Number.isInteger(count)) throw new ValidationError("count must be an integer");
|
||||
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||
throw new ValidationError(`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildMilestoneContextString(context: FeatureSuggestionContext): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`Roadmap: ${context.roadmapTitle}`);
|
||||
if (context.roadmapDescription) lines.push(`Description: ${context.roadmapDescription}`);
|
||||
lines.push("", `Milestone: ${context.milestoneTitle}`);
|
||||
if (context.milestoneDescription) lines.push(`Description: ${context.milestoneDescription}`);
|
||||
if (context.existingFeatureTitles.length > 0) {
|
||||
lines.push("", "Existing features in this milestone:");
|
||||
for (const title of context.existingFeatureTitles) lines.push(` - ${title}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function parseFeatureSuggestions(text: string): FeatureSuggestion[] {
|
||||
return parseMilestoneSuggestions(text);
|
||||
}
|
||||
|
||||
export async function generateFeatureSuggestions(
|
||||
context: FeatureSuggestionContext,
|
||||
count: number = DEFAULT_SUGGESTION_COUNT,
|
||||
prompt?: string,
|
||||
rootDir?: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
createAiSession?: CreateAiSessionFactory,
|
||||
): Promise<FeatureSuggestion[]> {
|
||||
const factory = pickFactory(createAiSession);
|
||||
if (!factory) throw new ServiceUnavailableError("AI service is not available");
|
||||
if (!rootDir) throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||
|
||||
const systemPrompt = `${FEATURE_SUGGESTION_SYSTEM_PROMPT}\n\n${buildMilestoneContextString(context)}`;
|
||||
const userMessage = prompt?.trim()
|
||||
? `Please suggest ${count} features for the milestone described above.\n\nAdditional guidance:\n${prompt.trim()}`
|
||||
: `Please suggest ${count} features for the milestone described above.`;
|
||||
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
const { text, dispose } = await runPrompt(factory, {
|
||||
cwd: rootDir,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||
}, userMessage);
|
||||
try {
|
||||
return parseFeatureSuggestions(text).slice(0, count);
|
||||
} finally {
|
||||
dispose?.();
|
||||
}
|
||||
})(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS),
|
||||
),
|
||||
]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ParseError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ServiceUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetSuggestionState(): void {
|
||||
injectedCreateAiSession = undefined;
|
||||
}
|
||||
|
||||
export function __setCreateFnAgent(mock: LegacyCreateFnAgent | undefined): void {
|
||||
if (!mock) {
|
||||
injectedCreateAiSession = undefined;
|
||||
return;
|
||||
}
|
||||
injectedCreateAiSession = async (options) => mock(options);
|
||||
}
|
||||
|
||||
export function __setCreateAiSessionFactory(mock: CreateAiSessionFactory | undefined): void {
|
||||
injectedCreateAiSession = mock;
|
||||
}
|
||||
1
plugins/fusion-plugin-roadmap/src/server/index.d.ts
vendored
Normal file
1
plugins/fusion-plugin-roadmap/src/server/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export { createRoadmapPluginRoutes } from "../routes/roadmap-routes.js";
|
||||
@@ -1 +1 @@
|
||||
export { createRoadmapPluginRoutes } from "../roadmap-routes.js";
|
||||
export { createRoadmapPluginRoutes } from "../routes/roadmap-routes.js";
|
||||
|
||||
37
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts
vendored
Normal file
37
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { RoadmapFeature, RoadmapFeatureMoveInput, RoadmapFeatureMoveResult, RoadmapFeatureReorderInput, RoadmapMilestone, RoadmapMilestoneReorderInput } from "../roadmap-types.js";
|
||||
/**
|
||||
* Repairs milestone ordering for a single roadmap scope.
|
||||
*
|
||||
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||
*/
|
||||
export declare function normalizeRoadmapMilestoneOrder(milestones: readonly RoadmapMilestone[]): RoadmapMilestone[];
|
||||
/**
|
||||
* Applies an explicit milestone reorder for a single roadmap scope.
|
||||
*
|
||||
* The caller must provide the complete milestone ID set exactly once. Partial
|
||||
* or duplicate lists are rejected to keep reorders deterministic.
|
||||
*/
|
||||
export declare function applyRoadmapMilestoneReorder(milestones: readonly RoadmapMilestone[], input: RoadmapMilestoneReorderInput): RoadmapMilestone[];
|
||||
/**
|
||||
* Repairs feature ordering for a single milestone scope.
|
||||
*
|
||||
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||
*/
|
||||
export declare function normalizeRoadmapFeatureOrder(features: readonly RoadmapFeature[]): RoadmapFeature[];
|
||||
/**
|
||||
* Applies an explicit feature reorder for a single milestone scope.
|
||||
*
|
||||
* The caller must provide the complete feature ID set exactly once. Partial or
|
||||
* duplicate lists are rejected to keep reorder behavior deterministic.
|
||||
*/
|
||||
export declare function applyRoadmapFeatureReorder(features: readonly RoadmapFeature[], input: RoadmapFeatureReorderInput): RoadmapFeature[];
|
||||
/**
|
||||
* Moves a feature within the affected milestone scope and deterministically
|
||||
* normalizes both source and destination order.
|
||||
*
|
||||
* For cross-milestone moves, pass the combined feature list from the source and
|
||||
* target milestones. For within-milestone moves, pass the current milestone's
|
||||
* feature list. `targetOrderIndex` is clamped into the destination range.
|
||||
*/
|
||||
export declare function moveRoadmapFeature(features: readonly RoadmapFeature[], input: RoadmapFeatureMoveInput): RoadmapFeatureMoveResult;
|
||||
//# sourceMappingURL=roadmap-ordering.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"roadmap-ordering.d.ts","sourceRoot":"","sources":["roadmap-ordering.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,uBAAuB,EACvB,wBAAwB,EACxB,0BAA0B,EAC1B,gBAAgB,EAChB,4BAA4B,EAC7B,MAAM,qBAAqB,CAAC;AA8I7B;;;;GAIG;AACH,wBAAgB,8BAA8B,CAC5C,UAAU,EAAE,SAAS,gBAAgB,EAAE,GACtC,gBAAgB,EAAE,CAUpB;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,UAAU,EAAE,SAAS,gBAAgB,EAAE,EACvC,KAAK,EAAE,4BAA4B,GAClC,gBAAgB,EAAE,CAWpB;AAED;;;;GAIG;AACH,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,SAAS,cAAc,EAAE,GAClC,cAAc,EAAE,CAUlB;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,SAAS,cAAc,EAAE,EACnC,KAAK,EAAE,0BAA0B,GAChC,cAAc,EAAE,CAWlB;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,SAAS,cAAc,EAAE,EACnC,KAAK,EAAE,uBAAuB,GAC7B,wBAAwB,CAoE1B"}
|
||||
188
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js
Normal file
188
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js
Normal file
@@ -0,0 +1,188 @@
|
||||
function compareOrderedEntities(a, b) {
|
||||
if (a.orderIndex !== b.orderIndex) {
|
||||
return a.orderIndex - b.orderIndex;
|
||||
}
|
||||
if (a.createdAt !== b.createdAt) {
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
}
|
||||
function clampInsertionIndex(targetIndex, length) {
|
||||
if (!Number.isFinite(targetIndex)) {
|
||||
return length;
|
||||
}
|
||||
const normalized = Math.trunc(targetIndex);
|
||||
if (normalized < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (normalized > length) {
|
||||
return length;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
function assertScopedRoadmapMilestones(milestones, roadmapId) {
|
||||
for (const milestone of milestones) {
|
||||
if (milestone.roadmapId !== roadmapId) {
|
||||
throw new Error(`Milestone ${milestone.id} does not belong to roadmap ${roadmapId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function assertScopedMilestoneFeatures(features, milestoneId) {
|
||||
for (const feature of features) {
|
||||
if (feature.milestoneId !== milestoneId) {
|
||||
throw new Error(`Feature ${feature.id} does not belong to milestone ${milestoneId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function assertScopedMoveFeatures(features, fromMilestoneId, toMilestoneId) {
|
||||
const validMilestoneIds = new Set([fromMilestoneId, toMilestoneId]);
|
||||
for (const feature of features) {
|
||||
if (!validMilestoneIds.has(feature.milestoneId)) {
|
||||
throw new Error(`Feature ${feature.id} is outside the affected milestone scope (${fromMilestoneId} → ${toMilestoneId})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function assertExactIdSet(entityLabel, actualIds, orderedIds) {
|
||||
const requestedIds = new Set();
|
||||
for (const id of orderedIds) {
|
||||
if (requestedIds.has(id)) {
|
||||
throw new Error(`Duplicate ${entityLabel} id in requested order: ${id}`);
|
||||
}
|
||||
requestedIds.add(id);
|
||||
}
|
||||
if (actualIds.length !== orderedIds.length) {
|
||||
throw new Error(`Expected ${actualIds.length} ${entityLabel} ids but received ${orderedIds.length}`);
|
||||
}
|
||||
const actualIdSet = new Set(actualIds);
|
||||
for (const id of orderedIds) {
|
||||
if (!actualIdSet.has(id)) {
|
||||
throw new Error(`${capitalize(entityLabel)} ${id} not found in scoped list`);
|
||||
}
|
||||
}
|
||||
for (const id of actualIds) {
|
||||
if (!requestedIds.has(id)) {
|
||||
throw new Error(`Missing ${entityLabel} id in requested order: ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function capitalize(value) {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
function assignContiguousOrder(items) {
|
||||
return items.map((item, orderIndex) => {
|
||||
if (item.orderIndex === orderIndex) {
|
||||
return { ...item };
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
orderIndex,
|
||||
};
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Repairs milestone ordering for a single roadmap scope.
|
||||
*
|
||||
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||
*/
|
||||
export function normalizeRoadmapMilestoneOrder(milestones) {
|
||||
if (milestones.length === 0) {
|
||||
return [];
|
||||
}
|
||||
assertScopedRoadmapMilestones(milestones, milestones[0].roadmapId);
|
||||
return assignContiguousOrder([...milestones].sort(compareOrderedEntities));
|
||||
}
|
||||
/**
|
||||
* Applies an explicit milestone reorder for a single roadmap scope.
|
||||
*
|
||||
* The caller must provide the complete milestone ID set exactly once. Partial
|
||||
* or duplicate lists are rejected to keep reorders deterministic.
|
||||
*/
|
||||
export function applyRoadmapMilestoneReorder(milestones, input) {
|
||||
assertScopedRoadmapMilestones(milestones, input.roadmapId);
|
||||
const normalized = normalizeRoadmapMilestoneOrder(milestones);
|
||||
const ids = normalized.map((milestone) => milestone.id);
|
||||
assertExactIdSet("milestone", ids, input.orderedMilestoneIds);
|
||||
const byId = new Map(normalized.map((milestone) => [milestone.id, milestone]));
|
||||
return assignContiguousOrder(input.orderedMilestoneIds.map((id) => byId.get(id)));
|
||||
}
|
||||
/**
|
||||
* Repairs feature ordering for a single milestone scope.
|
||||
*
|
||||
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||
*/
|
||||
export function normalizeRoadmapFeatureOrder(features) {
|
||||
if (features.length === 0) {
|
||||
return [];
|
||||
}
|
||||
assertScopedMilestoneFeatures(features, features[0].milestoneId);
|
||||
return assignContiguousOrder([...features].sort(compareOrderedEntities));
|
||||
}
|
||||
/**
|
||||
* Applies an explicit feature reorder for a single milestone scope.
|
||||
*
|
||||
* The caller must provide the complete feature ID set exactly once. Partial or
|
||||
* duplicate lists are rejected to keep reorder behavior deterministic.
|
||||
*/
|
||||
export function applyRoadmapFeatureReorder(features, input) {
|
||||
assertScopedMilestoneFeatures(features, input.milestoneId);
|
||||
const normalized = normalizeRoadmapFeatureOrder(features);
|
||||
const ids = normalized.map((feature) => feature.id);
|
||||
assertExactIdSet("feature", ids, input.orderedFeatureIds);
|
||||
const byId = new Map(normalized.map((feature) => [feature.id, feature]));
|
||||
return assignContiguousOrder(input.orderedFeatureIds.map((id) => byId.get(id)));
|
||||
}
|
||||
/**
|
||||
* Moves a feature within the affected milestone scope and deterministically
|
||||
* normalizes both source and destination order.
|
||||
*
|
||||
* For cross-milestone moves, pass the combined feature list from the source and
|
||||
* target milestones. For within-milestone moves, pass the current milestone's
|
||||
* feature list. `targetOrderIndex` is clamped into the destination range.
|
||||
*/
|
||||
export function moveRoadmapFeature(features, input) {
|
||||
assertScopedMoveFeatures(features, input.fromMilestoneId, input.toMilestoneId);
|
||||
const existingFeature = features.find((feature) => feature.id === input.featureId);
|
||||
if (!existingFeature) {
|
||||
throw new Error(`Feature ${input.featureId} not found in affected milestone scope`);
|
||||
}
|
||||
if (existingFeature.milestoneId !== input.fromMilestoneId) {
|
||||
throw new Error(`Feature ${input.featureId} does not belong to milestone ${input.fromMilestoneId}`);
|
||||
}
|
||||
const sourceFeatures = normalizeRoadmapFeatureOrder(features.filter((feature) => feature.milestoneId === input.fromMilestoneId));
|
||||
const sourceWithoutFeature = sourceFeatures.filter((feature) => feature.id !== input.featureId);
|
||||
if (input.fromMilestoneId === input.toMilestoneId) {
|
||||
const insertionIndex = clampInsertionIndex(input.targetOrderIndex, sourceWithoutFeature.length);
|
||||
const reordered = [...sourceWithoutFeature];
|
||||
reordered.splice(insertionIndex, 0, {
|
||||
...existingFeature,
|
||||
milestoneId: input.toMilestoneId,
|
||||
orderIndex: insertionIndex,
|
||||
});
|
||||
const normalized = assignContiguousOrder(reordered);
|
||||
const movedFeature = normalized.find((feature) => feature.id === input.featureId);
|
||||
return {
|
||||
movedFeature,
|
||||
affectedFeatures: normalized,
|
||||
sourceMilestoneFeatures: normalized,
|
||||
targetMilestoneFeatures: normalized,
|
||||
};
|
||||
}
|
||||
const targetFeatures = normalizeRoadmapFeatureOrder(features.filter((feature) => feature.milestoneId === input.toMilestoneId));
|
||||
const insertionIndex = clampInsertionIndex(input.targetOrderIndex, targetFeatures.length);
|
||||
const targetWithInsertedFeature = [...targetFeatures];
|
||||
targetWithInsertedFeature.splice(insertionIndex, 0, {
|
||||
...existingFeature,
|
||||
milestoneId: input.toMilestoneId,
|
||||
orderIndex: insertionIndex,
|
||||
});
|
||||
const normalizedSource = assignContiguousOrder(sourceWithoutFeature);
|
||||
const normalizedTarget = assignContiguousOrder(targetWithInsertedFeature);
|
||||
const movedFeature = normalizedTarget.find((feature) => feature.id === input.featureId);
|
||||
return {
|
||||
movedFeature,
|
||||
affectedFeatures: [...normalizedSource, ...normalizedTarget],
|
||||
sourceMilestoneFeatures: normalizedSource,
|
||||
targetMilestoneFeatures: normalizedTarget,
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=roadmap-ordering.js.map
|
||||
File diff suppressed because one or more lines are too long
299
plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts
vendored
Normal file
299
plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts
vendored
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* RoadmapStore - Data layer for standalone roadmap persistence.
|
||||
*
|
||||
* Manages CRUD operations for roadmaps, milestones, and features.
|
||||
* Provides deterministic ordering via covering indexes and atomic reorder/move operations.
|
||||
*
|
||||
* Ordering invariants:
|
||||
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
|
||||
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
|
||||
* - all list/read queries use deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||
* - cross-milestone feature moves atomically renumber both affected milestone scopes
|
||||
*/
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Database } from "@fusion/core";
|
||||
import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapCreateInput, RoadmapUpdateInput, RoadmapMilestoneCreateInput, RoadmapMilestoneUpdateInput, RoadmapFeatureCreateInput, RoadmapFeatureUpdateInput, RoadmapMilestoneReorderInput, RoadmapFeatureReorderInput, RoadmapFeatureMoveInput, RoadmapMilestoneWithFeatures, RoadmapWithHierarchy, RoadmapExportBundle, RoadmapMissionPlanningHandoff, RoadmapFeatureTaskPlanningHandoff } from "../roadmap-types.js";
|
||||
export interface RoadmapStoreEvents {
|
||||
/** Emitted when a roadmap is created */
|
||||
"roadmap:created": [Roadmap];
|
||||
/** Emitted when a roadmap is updated */
|
||||
"roadmap:updated": [Roadmap];
|
||||
/** Emitted when a roadmap is deleted */
|
||||
"roadmap:deleted": [string];
|
||||
/** Emitted when a milestone is created */
|
||||
"milestone:created": [RoadmapMilestone];
|
||||
/** Emitted when a milestone is updated */
|
||||
"milestone:updated": [RoadmapMilestone];
|
||||
/** Emitted when a milestone is deleted */
|
||||
"milestone:deleted": [string];
|
||||
/** Emitted when a milestone is reordered */
|
||||
"milestone:reordered": [{
|
||||
roadmapId: string;
|
||||
milestones: RoadmapMilestone[];
|
||||
}];
|
||||
/** Emitted when a feature is created */
|
||||
"feature:created": [RoadmapFeature];
|
||||
/** Emitted when a feature is updated */
|
||||
"feature:updated": [RoadmapFeature];
|
||||
/** Emitted when a feature is deleted */
|
||||
"feature:deleted": [RoadmapFeature];
|
||||
/** Emitted when features are reordered within a milestone */
|
||||
"feature:reordered": [{
|
||||
milestoneId: string;
|
||||
features: RoadmapFeature[];
|
||||
}];
|
||||
/** Emitted when a feature is moved (including cross-milestone moves) */
|
||||
"feature:moved": [{
|
||||
feature: RoadmapFeature;
|
||||
fromMilestoneId: string;
|
||||
toMilestoneId: string;
|
||||
}];
|
||||
}
|
||||
export declare class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
|
||||
private db;
|
||||
/**
|
||||
* Creates a new RoadmapStore instance.
|
||||
*
|
||||
* @param db - Shared Database instance (same instance used by TaskStore)
|
||||
*/
|
||||
constructor(db: Database);
|
||||
private ensureSchema;
|
||||
private generateRoadmapId;
|
||||
private generateMilestoneId;
|
||||
private generateFeatureId;
|
||||
private rowToRoadmap;
|
||||
private rowToMilestone;
|
||||
private rowToFeature;
|
||||
/**
|
||||
* Create a new roadmap.
|
||||
*
|
||||
* @param input - Roadmap creation input
|
||||
* @returns The created roadmap
|
||||
*/
|
||||
createRoadmap(input: RoadmapCreateInput): Roadmap;
|
||||
/**
|
||||
* Get a roadmap by ID.
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @returns The roadmap, or undefined if not found
|
||||
*/
|
||||
getRoadmap(id: string): Roadmap | undefined;
|
||||
/**
|
||||
* List all roadmaps, ordered by creation date (newest first).
|
||||
*
|
||||
* @returns Array of roadmaps
|
||||
*/
|
||||
listRoadmaps(): Roadmap[];
|
||||
/**
|
||||
* Update a roadmap.
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @param updates - Partial roadmap updates
|
||||
* @returns The updated roadmap
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
updateRoadmap(id: string, updates: RoadmapUpdateInput): Roadmap;
|
||||
/**
|
||||
* Delete a roadmap and all its milestones/features (cascading).
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
deleteRoadmap(id: string): void;
|
||||
/**
|
||||
* Add a milestone to a roadmap.
|
||||
* Automatically computes the orderIndex (max + 1).
|
||||
*
|
||||
* @param roadmapId - Parent roadmap ID
|
||||
* @param input - Milestone creation input
|
||||
* @returns The created milestone
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
createMilestone(roadmapId: string, input: RoadmapMilestoneCreateInput): RoadmapMilestone;
|
||||
/**
|
||||
* Get a milestone by ID.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @returns The milestone, or undefined if not found
|
||||
*/
|
||||
getMilestone(id: string): RoadmapMilestone | undefined;
|
||||
/**
|
||||
* List milestones for a roadmap, ordered deterministically.
|
||||
*
|
||||
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||
* to ensure consistent results when stored order data is incomplete or conflicting.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns Array of milestones in deterministic order
|
||||
*/
|
||||
listMilestones(roadmapId: string): RoadmapMilestone[];
|
||||
/**
|
||||
* Update a milestone.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @param updates - Partial milestone updates
|
||||
* @returns The updated milestone
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
updateMilestone(id: string, updates: RoadmapMilestoneUpdateInput): RoadmapMilestone;
|
||||
/**
|
||||
* Delete a milestone and all its features (cascading).
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
deleteMilestone(id: string): void;
|
||||
/**
|
||||
* Add a feature to a milestone.
|
||||
* Automatically computes the orderIndex (max + 1).
|
||||
*
|
||||
* @param milestoneId - Parent milestone ID
|
||||
* @param input - Feature creation input
|
||||
* @returns The created feature
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
createFeature(milestoneId: string, input: RoadmapFeatureCreateInput): RoadmapFeature;
|
||||
/**
|
||||
* Get a feature by ID.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @returns The feature, or undefined if not found
|
||||
*/
|
||||
getFeature(id: string): RoadmapFeature | undefined;
|
||||
/**
|
||||
* List features for a milestone, ordered deterministically.
|
||||
*
|
||||
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||
* to ensure consistent results when stored order data is incomplete or conflicting.
|
||||
*
|
||||
* @param milestoneId - Milestone ID
|
||||
* @returns Array of features in deterministic order
|
||||
*/
|
||||
listFeatures(milestoneId: string): RoadmapFeature[];
|
||||
/**
|
||||
* Update a feature.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @param updates - Partial feature updates
|
||||
* @returns The updated feature
|
||||
* @throws Error if feature not found
|
||||
*/
|
||||
updateFeature(id: string, updates: RoadmapFeatureUpdateInput): RoadmapFeature;
|
||||
/**
|
||||
* Delete a feature.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @throws Error if feature not found
|
||||
*/
|
||||
deleteFeature(id: string): void;
|
||||
/**
|
||||
* Reorder milestones within a roadmap.
|
||||
*
|
||||
* Applies an explicit reorder input and persists the full normalized order.
|
||||
* The input must contain all milestone IDs exactly once.
|
||||
*
|
||||
* @param input - Reorder input with complete milestone ID list
|
||||
* @returns The reordered milestones in their new order
|
||||
* @throws Error if milestone set is incomplete, duplicate, or not found
|
||||
*/
|
||||
reorderMilestones(input: RoadmapMilestoneReorderInput): RoadmapMilestone[];
|
||||
/**
|
||||
* Reorder features within a milestone.
|
||||
*
|
||||
* Applies an explicit reorder input and persists the full normalized order.
|
||||
* The input must contain all feature IDs for the milestone exactly once.
|
||||
*
|
||||
* @param input - Reorder input with complete feature ID list
|
||||
* @returns The reordered features in their new order
|
||||
* @throws Error if feature set is incomplete, duplicate, or not found
|
||||
*/
|
||||
reorderFeatures(input: RoadmapFeatureReorderInput): RoadmapFeature[];
|
||||
/**
|
||||
* Move a feature, including cross-milestone moves.
|
||||
*
|
||||
* Atomically renumbers both the source and destination milestone scopes.
|
||||
*
|
||||
* @param input - Move input with source/destination milestone info
|
||||
* @returns The moved feature and both affected milestone feature lists
|
||||
* @throws Error if feature or milestone not found, or scope validation fails
|
||||
*/
|
||||
moveFeature(input: RoadmapFeatureMoveInput): {
|
||||
movedFeature: RoadmapFeature;
|
||||
sourceMilestoneFeatures: RoadmapFeature[];
|
||||
targetMilestoneFeatures: RoadmapFeature[];
|
||||
};
|
||||
/**
|
||||
* Get a milestone with all of its features in deterministic order.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @returns The milestone with features, or undefined if not found
|
||||
*/
|
||||
getMilestoneWithFeatures(id: string): RoadmapMilestoneWithFeatures | undefined;
|
||||
/**
|
||||
* Get a roadmap with its full hierarchy (milestones → features).
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @returns The roadmap with hierarchy, or undefined if not found
|
||||
*/
|
||||
getRoadmapWithHierarchy(id: string): RoadmapWithHierarchy | undefined;
|
||||
/**
|
||||
* Get a flat export bundle for a roadmap.
|
||||
*
|
||||
* Returns all roadmap data in a flat structure suitable for persistence,
|
||||
* APIs, import/export, and sync jobs. Entities are separated so downstream
|
||||
* persistence layers can upsert by table/collection.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The export bundle with ordered entities
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getRoadmapExport(roadmapId: string): RoadmapExportBundle;
|
||||
/**
|
||||
* Get a mission planning handoff payload for a roadmap.
|
||||
*
|
||||
* Converts the roadmap into a mission planning structure while preserving
|
||||
* source IDs and deterministic order. Does not couple to MissionStore internals.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The mission planning handoff payload
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getRoadmapMissionHandoff(roadmapId: string): RoadmapMissionPlanningHandoff;
|
||||
/**
|
||||
* Get a task planning handoff payload for a single roadmap feature.
|
||||
*
|
||||
* Returns a self-contained handoff payload for converting a roadmap feature
|
||||
* into task planning flows without coupling to MissionStore internals.
|
||||
*
|
||||
* @param roadmapId - Parent roadmap ID (for validation)
|
||||
* @param milestoneId - Parent milestone ID (for validation)
|
||||
* @param featureId - Feature ID to generate handoff for
|
||||
* @returns The task planning handoff payload
|
||||
* @throws Error if any entity is not found or if ownership validation fails
|
||||
*/
|
||||
getRoadmapFeatureHandoff(roadmapId: string, milestoneId: string, featureId: string): RoadmapFeatureTaskPlanningHandoff;
|
||||
/**
|
||||
* Get a mission planning handoff payload for a roadmap.
|
||||
*
|
||||
* Alias for getRoadmapMissionHandoff() for API consistency.
|
||||
* Converts the roadmap into a mission planning structure while preserving
|
||||
* source IDs and deterministic order.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The mission planning handoff payload
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getMissionPlanningHandoff(roadmapId: string): RoadmapMissionPlanningHandoff;
|
||||
/**
|
||||
* List all task planning handoff payloads for a roadmap.
|
||||
*
|
||||
* Returns a flat list of all feature handoffs in deterministic order
|
||||
* (milestone order index, then feature order index).
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns Array of task planning handoff payloads for all features
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
listFeatureTaskPlanningHandoffs(roadmapId: string): RoadmapFeatureTaskPlanningHandoff[];
|
||||
}
|
||||
//# sourceMappingURL=roadmap-store.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"roadmap-store.d.ts","sourceRoot":"","sources":["roadmap-store.ts"],"names":[],"mappings":"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"}
|
||||
765
plugins/fusion-plugin-roadmap/src/store/roadmap-store.js
Normal file
765
plugins/fusion-plugin-roadmap/src/store/roadmap-store.js
Normal file
@@ -0,0 +1,765 @@
|
||||
/**
|
||||
* RoadmapStore - Data layer for standalone roadmap persistence.
|
||||
*
|
||||
* Manages CRUD operations for roadmaps, milestones, and features.
|
||||
* Provides deterministic ordering via covering indexes and atomic reorder/move operations.
|
||||
*
|
||||
* Ordering invariants:
|
||||
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
|
||||
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
|
||||
* - all list/read queries use deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||
* - cross-milestone feature moves atomically renumber both affected milestone scopes
|
||||
*/
|
||||
import { EventEmitter } from "node:events";
|
||||
import { applyRoadmapMilestoneReorder, applyRoadmapFeatureReorder, moveRoadmapFeature, } from "./roadmap-ordering.js";
|
||||
// ── RoadmapStore Class ──────────────────────────────────────────────
|
||||
export class RoadmapStore extends EventEmitter {
|
||||
db;
|
||||
/**
|
||||
* Creates a new RoadmapStore instance.
|
||||
*
|
||||
* @param db - Shared Database instance (same instance used by TaskStore)
|
||||
*/
|
||||
constructor(db) {
|
||||
super();
|
||||
this.db = db;
|
||||
this.setMaxListeners(50);
|
||||
this.ensureSchema();
|
||||
}
|
||||
ensureSchema() {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS roadmaps (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roadmap_milestones (
|
||||
id TEXT PRIMARY KEY,
|
||||
roadmapId TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
orderIndex INTEGER NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (roadmapId) REFERENCES roadmaps(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roadmap_features (
|
||||
id TEXT PRIMARY KEY,
|
||||
milestoneId TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
orderIndex INTEGER NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (milestoneId) REFERENCES roadmap_milestones(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idxRoadmapMilestonesRoadmapOrder
|
||||
ON roadmap_milestones(roadmapId, orderIndex, createdAt, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idxRoadmapFeaturesMilestoneOrder
|
||||
ON roadmap_features(milestoneId, orderIndex, createdAt, id);
|
||||
`);
|
||||
}
|
||||
// ── ID Generators ───────────────────────────────────────────────────
|
||||
generateRoadmapId() {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `RM-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
generateMilestoneId() {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `RMS-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
generateFeatureId() {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `RF-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
// ── Row-to-Object Converters ───────────────────────────────────────
|
||||
rowToRoadmap(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description || undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
rowToMilestone(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
roadmapId: row.roadmapId,
|
||||
title: row.title,
|
||||
description: row.description || undefined,
|
||||
orderIndex: row.orderIndex,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
rowToFeature(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
milestoneId: row.milestoneId,
|
||||
title: row.title,
|
||||
description: row.description || undefined,
|
||||
orderIndex: row.orderIndex,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
// ── Roadmap CRUD ─────────────────────────────────────────────────
|
||||
/**
|
||||
* Create a new roadmap.
|
||||
*
|
||||
* @param input - Roadmap creation input
|
||||
* @returns The created roadmap
|
||||
*/
|
||||
createRoadmap(input) {
|
||||
const now = new Date().toISOString();
|
||||
const id = this.generateRoadmapId();
|
||||
const roadmap = {
|
||||
id,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.db.prepare(`
|
||||
INSERT INTO roadmaps (id, title, description, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(roadmap.id, roadmap.title, roadmap.description ?? null, roadmap.createdAt, roadmap.updatedAt);
|
||||
this.db.bumpLastModified();
|
||||
this.emit("roadmap:created", roadmap);
|
||||
return roadmap;
|
||||
}
|
||||
/**
|
||||
* Get a roadmap by ID.
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @returns The roadmap, or undefined if not found
|
||||
*/
|
||||
getRoadmap(id) {
|
||||
const row = this.db.prepare("SELECT * FROM roadmaps WHERE id = ?").get(id);
|
||||
if (!row)
|
||||
return undefined;
|
||||
return this.rowToRoadmap(row);
|
||||
}
|
||||
/**
|
||||
* List all roadmaps, ordered by creation date (newest first).
|
||||
*
|
||||
* @returns Array of roadmaps
|
||||
*/
|
||||
listRoadmaps() {
|
||||
const rows = this.db.prepare("SELECT * FROM roadmaps ORDER BY createdAt DESC").all();
|
||||
return rows.map((row) => this.rowToRoadmap(row));
|
||||
}
|
||||
/**
|
||||
* Update a roadmap.
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @param updates - Partial roadmap updates
|
||||
* @returns The updated roadmap
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
updateRoadmap(id, updates) {
|
||||
const roadmap = this.getRoadmap(id);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${id} not found`);
|
||||
}
|
||||
const updated = {
|
||||
...roadmap,
|
||||
...updates,
|
||||
id, // Prevent changing ID
|
||||
createdAt: roadmap.createdAt, // Prevent changing creation time
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.db.prepare(`
|
||||
UPDATE roadmaps SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(updated.title, updated.description ?? null, updated.updatedAt, updated.id);
|
||||
this.db.bumpLastModified();
|
||||
this.emit("roadmap:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
/**
|
||||
* Delete a roadmap and all its milestones/features (cascading).
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
deleteRoadmap(id) {
|
||||
const roadmap = this.getRoadmap(id);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${id} not found`);
|
||||
}
|
||||
// SQLite FK cascade will handle milestones and features
|
||||
this.db.prepare("DELETE FROM roadmaps WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
this.emit("roadmap:deleted", id);
|
||||
}
|
||||
// ── Milestone CRUD ────────────────────────────────────────────────
|
||||
/**
|
||||
* Add a milestone to a roadmap.
|
||||
* Automatically computes the orderIndex (max + 1).
|
||||
*
|
||||
* @param roadmapId - Parent roadmap ID
|
||||
* @param input - Milestone creation input
|
||||
* @returns The created milestone
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
createMilestone(roadmapId, input) {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const id = this.generateMilestoneId();
|
||||
// Compute next orderIndex
|
||||
const existingMilestones = this.listMilestones(roadmapId);
|
||||
const orderIndex = existingMilestones.length > 0
|
||||
? Math.max(...existingMilestones.map((m) => m.orderIndex)) + 1
|
||||
: 0;
|
||||
const milestone = {
|
||||
id,
|
||||
roadmapId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
orderIndex,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.db.prepare(`
|
||||
INSERT INTO roadmap_milestones (id, roadmapId, title, description, orderIndex, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(milestone.id, milestone.roadmapId, milestone.title, milestone.description ?? null, milestone.orderIndex, milestone.createdAt, milestone.updatedAt);
|
||||
this.db.bumpLastModified();
|
||||
this.emit("milestone:created", milestone);
|
||||
return milestone;
|
||||
}
|
||||
/**
|
||||
* Get a milestone by ID.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @returns The milestone, or undefined if not found
|
||||
*/
|
||||
getMilestone(id) {
|
||||
const row = this.db.prepare("SELECT * FROM roadmap_milestones WHERE id = ?").get(id);
|
||||
if (!row)
|
||||
return undefined;
|
||||
return this.rowToMilestone(row);
|
||||
}
|
||||
/**
|
||||
* List milestones for a roadmap, ordered deterministically.
|
||||
*
|
||||
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||
* to ensure consistent results when stored order data is incomplete or conflicting.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns Array of milestones in deterministic order
|
||||
*/
|
||||
listMilestones(roadmapId) {
|
||||
const rows = this.db.prepare("SELECT * FROM roadmap_milestones WHERE roadmapId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC").all(roadmapId);
|
||||
return rows.map((row) => this.rowToMilestone(row));
|
||||
}
|
||||
/**
|
||||
* Update a milestone.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @param updates - Partial milestone updates
|
||||
* @returns The updated milestone
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
updateMilestone(id, updates) {
|
||||
const milestone = this.getMilestone(id);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${id} not found`);
|
||||
}
|
||||
const updated = {
|
||||
...milestone,
|
||||
...updates,
|
||||
id, // Prevent changing ID
|
||||
roadmapId: milestone.roadmapId, // Prevent moving to different roadmap
|
||||
createdAt: milestone.createdAt, // Prevent changing creation time
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_milestones SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(updated.title, updated.description ?? null, updated.updatedAt, updated.id);
|
||||
this.db.bumpLastModified();
|
||||
this.emit("milestone:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
/**
|
||||
* Delete a milestone and all its features (cascading).
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
deleteMilestone(id) {
|
||||
const milestone = this.getMilestone(id);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${id} not found`);
|
||||
}
|
||||
// SQLite FK cascade will handle features
|
||||
this.db.prepare("DELETE FROM roadmap_milestones WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
this.emit("milestone:deleted", id);
|
||||
}
|
||||
// ── Feature CRUD ─────────────────────────────────────────────────
|
||||
/**
|
||||
* Add a feature to a milestone.
|
||||
* Automatically computes the orderIndex (max + 1).
|
||||
*
|
||||
* @param milestoneId - Parent milestone ID
|
||||
* @param input - Feature creation input
|
||||
* @returns The created feature
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
createFeature(milestoneId, input) {
|
||||
const milestone = this.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${milestoneId} not found`);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const id = this.generateFeatureId();
|
||||
// Compute next orderIndex
|
||||
const existingFeatures = this.listFeatures(milestoneId);
|
||||
const orderIndex = existingFeatures.length > 0
|
||||
? Math.max(...existingFeatures.map((f) => f.orderIndex)) + 1
|
||||
: 0;
|
||||
const feature = {
|
||||
id,
|
||||
milestoneId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
orderIndex,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.db.prepare(`
|
||||
INSERT INTO roadmap_features (id, milestoneId, title, description, orderIndex, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(feature.id, feature.milestoneId, feature.title, feature.description ?? null, feature.orderIndex, feature.createdAt, feature.updatedAt);
|
||||
this.db.bumpLastModified();
|
||||
this.emit("feature:created", feature);
|
||||
return feature;
|
||||
}
|
||||
/**
|
||||
* Get a feature by ID.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @returns The feature, or undefined if not found
|
||||
*/
|
||||
getFeature(id) {
|
||||
const row = this.db.prepare("SELECT * FROM roadmap_features WHERE id = ?").get(id);
|
||||
if (!row)
|
||||
return undefined;
|
||||
return this.rowToFeature(row);
|
||||
}
|
||||
/**
|
||||
* List features for a milestone, ordered deterministically.
|
||||
*
|
||||
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||
* to ensure consistent results when stored order data is incomplete or conflicting.
|
||||
*
|
||||
* @param milestoneId - Milestone ID
|
||||
* @returns Array of features in deterministic order
|
||||
*/
|
||||
listFeatures(milestoneId) {
|
||||
const rows = this.db.prepare("SELECT * FROM roadmap_features WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC").all(milestoneId);
|
||||
return rows.map((row) => this.rowToFeature(row));
|
||||
}
|
||||
/**
|
||||
* Update a feature.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @param updates - Partial feature updates
|
||||
* @returns The updated feature
|
||||
* @throws Error if feature not found
|
||||
*/
|
||||
updateFeature(id, updates) {
|
||||
const feature = this.getFeature(id);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${id} not found`);
|
||||
}
|
||||
const updated = {
|
||||
...feature,
|
||||
...updates,
|
||||
id, // Prevent changing ID
|
||||
milestoneId: feature.milestoneId, // Prevent moving via update (use moveFeature instead)
|
||||
createdAt: feature.createdAt, // Prevent changing creation time
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_features SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(updated.title, updated.description ?? null, updated.updatedAt, updated.id);
|
||||
this.db.bumpLastModified();
|
||||
this.emit("feature:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
/**
|
||||
* Delete a feature.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @throws Error if feature not found
|
||||
*/
|
||||
deleteFeature(id) {
|
||||
const feature = this.getFeature(id);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${id} not found`);
|
||||
}
|
||||
this.db.prepare("DELETE FROM roadmap_features WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
this.emit("feature:deleted", feature);
|
||||
}
|
||||
// ── Reorder Operations ────────────────────────────────────────────
|
||||
/**
|
||||
* Reorder milestones within a roadmap.
|
||||
*
|
||||
* Applies an explicit reorder input and persists the full normalized order.
|
||||
* The input must contain all milestone IDs exactly once.
|
||||
*
|
||||
* @param input - Reorder input with complete milestone ID list
|
||||
* @returns The reordered milestones in their new order
|
||||
* @throws Error if milestone set is incomplete, duplicate, or not found
|
||||
*/
|
||||
reorderMilestones(input) {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(input.roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${input.roadmapId} not found`);
|
||||
}
|
||||
// Load current milestones with deterministic ordering
|
||||
const milestones = this.listMilestones(input.roadmapId);
|
||||
// Apply the reorder using the pure ordering helper
|
||||
const reordered = applyRoadmapMilestoneReorder(milestones, input);
|
||||
// Persist in a transaction
|
||||
this.db.transaction(() => {
|
||||
for (const milestone of reordered) {
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_milestones SET orderIndex = ?, updatedAt = ? WHERE id = ?
|
||||
`).run(milestone.orderIndex, new Date().toISOString(), milestone.id);
|
||||
}
|
||||
});
|
||||
this.db.bumpLastModified();
|
||||
this.emit("milestone:reordered", { roadmapId: input.roadmapId, milestones: reordered });
|
||||
return reordered;
|
||||
}
|
||||
/**
|
||||
* Reorder features within a milestone.
|
||||
*
|
||||
* Applies an explicit reorder input and persists the full normalized order.
|
||||
* The input must contain all feature IDs for the milestone exactly once.
|
||||
*
|
||||
* @param input - Reorder input with complete feature ID list
|
||||
* @returns The reordered features in their new order
|
||||
* @throws Error if feature set is incomplete, duplicate, or not found
|
||||
*/
|
||||
reorderFeatures(input) {
|
||||
// Validate milestone exists and belongs to the roadmap
|
||||
const milestone = this.getMilestone(input.milestoneId);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${input.milestoneId} not found`);
|
||||
}
|
||||
if (milestone.roadmapId !== input.roadmapId) {
|
||||
throw new Error(`Milestone ${input.milestoneId} does not belong to roadmap ${input.roadmapId}`);
|
||||
}
|
||||
// Load current features with deterministic ordering
|
||||
const features = this.listFeatures(input.milestoneId);
|
||||
// Apply the reorder using the pure ordering helper
|
||||
const reordered = applyRoadmapFeatureReorder(features, input);
|
||||
// Persist in a transaction
|
||||
this.db.transaction(() => {
|
||||
for (const feature of reordered) {
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_features SET orderIndex = ?, updatedAt = ? WHERE id = ?
|
||||
`).run(feature.orderIndex, new Date().toISOString(), feature.id);
|
||||
}
|
||||
});
|
||||
this.db.bumpLastModified();
|
||||
this.emit("feature:reordered", { milestoneId: input.milestoneId, features: reordered });
|
||||
return reordered;
|
||||
}
|
||||
/**
|
||||
* Move a feature, including cross-milestone moves.
|
||||
*
|
||||
* Atomically renumbers both the source and destination milestone scopes.
|
||||
*
|
||||
* @param input - Move input with source/destination milestone info
|
||||
* @returns The moved feature and both affected milestone feature lists
|
||||
* @throws Error if feature or milestone not found, or scope validation fails
|
||||
*/
|
||||
moveFeature(input) {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(input.roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${input.roadmapId} not found`);
|
||||
}
|
||||
// Validate both milestones exist and belong to the roadmap
|
||||
const fromMilestone = this.getMilestone(input.fromMilestoneId);
|
||||
const toMilestone = this.getMilestone(input.toMilestoneId);
|
||||
if (!fromMilestone) {
|
||||
throw new Error(`Source milestone ${input.fromMilestoneId} not found`);
|
||||
}
|
||||
if (!toMilestone) {
|
||||
throw new Error(`Destination milestone ${input.toMilestoneId} not found`);
|
||||
}
|
||||
if (fromMilestone.roadmapId !== input.roadmapId) {
|
||||
throw new Error(`Source milestone ${input.fromMilestoneId} does not belong to roadmap ${input.roadmapId}`);
|
||||
}
|
||||
if (toMilestone.roadmapId !== input.roadmapId) {
|
||||
throw new Error(`Destination milestone ${input.toMilestoneId} does not belong to roadmap ${input.roadmapId}`);
|
||||
}
|
||||
// Load features from both milestones with deterministic ordering
|
||||
const sourceFeatures = this.listFeatures(input.fromMilestoneId);
|
||||
const targetFeatures = this.listFeatures(input.toMilestoneId);
|
||||
// For same-milestone moves, pass only one list to avoid duplication
|
||||
// For cross-milestone moves, pass the combined list
|
||||
const allFeatures = input.fromMilestoneId === input.toMilestoneId
|
||||
? sourceFeatures
|
||||
: [...sourceFeatures, ...targetFeatures];
|
||||
// Apply the move using the pure ordering helper
|
||||
const result = moveRoadmapFeature(allFeatures, input);
|
||||
// Persist in a transaction
|
||||
this.db.transaction(() => {
|
||||
// Update all affected features
|
||||
for (const feature of result.affectedFeatures) {
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_features SET milestoneId = ?, orderIndex = ?, updatedAt = ? WHERE id = ?
|
||||
`).run(feature.milestoneId, feature.orderIndex, new Date().toISOString(), feature.id);
|
||||
}
|
||||
});
|
||||
this.db.bumpLastModified();
|
||||
this.emit("feature:moved", {
|
||||
feature: result.movedFeature,
|
||||
fromMilestoneId: input.fromMilestoneId,
|
||||
toMilestoneId: input.toMilestoneId,
|
||||
});
|
||||
return {
|
||||
movedFeature: result.movedFeature,
|
||||
sourceMilestoneFeatures: result.sourceMilestoneFeatures,
|
||||
targetMilestoneFeatures: result.targetMilestoneFeatures,
|
||||
};
|
||||
}
|
||||
// ── Hierarchy Operations ───────────────────────────────────────────
|
||||
/**
|
||||
* Get a milestone with all of its features in deterministic order.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @returns The milestone with features, or undefined if not found
|
||||
*/
|
||||
getMilestoneWithFeatures(id) {
|
||||
const milestone = this.getMilestone(id);
|
||||
if (!milestone)
|
||||
return undefined;
|
||||
return {
|
||||
...milestone,
|
||||
features: this.listFeatures(id),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Get a roadmap with its full hierarchy (milestones → features).
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @returns The roadmap with hierarchy, or undefined if not found
|
||||
*/
|
||||
getRoadmapWithHierarchy(id) {
|
||||
const roadmap = this.getRoadmap(id);
|
||||
if (!roadmap)
|
||||
return undefined;
|
||||
return {
|
||||
...roadmap,
|
||||
milestones: this.listMilestones(id).map((milestone) => ({
|
||||
...milestone,
|
||||
features: this.listFeatures(milestone.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
// ── Export / Handoff Operations ────────────────────────────────────
|
||||
/**
|
||||
* Get a flat export bundle for a roadmap.
|
||||
*
|
||||
* Returns all roadmap data in a flat structure suitable for persistence,
|
||||
* APIs, import/export, and sync jobs. Entities are separated so downstream
|
||||
* persistence layers can upsert by table/collection.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The export bundle with ordered entities
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getRoadmapExport(roadmapId) {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
const allFeatures = [];
|
||||
for (const milestone of milestones) {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
allFeatures.push(...features);
|
||||
}
|
||||
return {
|
||||
roadmap,
|
||||
milestones,
|
||||
features: allFeatures,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Get a mission planning handoff payload for a roadmap.
|
||||
*
|
||||
* Converts the roadmap into a mission planning structure while preserving
|
||||
* source IDs and deterministic order. Does not couple to MissionStore internals.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The mission planning handoff payload
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getRoadmapMissionHandoff(roadmapId) {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
return {
|
||||
sourceRoadmapId: roadmap.id,
|
||||
title: roadmap.title,
|
||||
description: roadmap.description,
|
||||
milestones: milestones.map((milestone) => {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
return {
|
||||
sourceMilestoneId: milestone.id,
|
||||
title: milestone.title,
|
||||
description: milestone.description,
|
||||
orderIndex: milestone.orderIndex,
|
||||
features: features.map((feature) => ({
|
||||
sourceFeatureId: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
orderIndex: feature.orderIndex,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Get a task planning handoff payload for a single roadmap feature.
|
||||
*
|
||||
* Returns a self-contained handoff payload for converting a roadmap feature
|
||||
* into task planning flows without coupling to MissionStore internals.
|
||||
*
|
||||
* @param roadmapId - Parent roadmap ID (for validation)
|
||||
* @param milestoneId - Parent milestone ID (for validation)
|
||||
* @param featureId - Feature ID to generate handoff for
|
||||
* @returns The task planning handoff payload
|
||||
* @throws Error if any entity is not found or if ownership validation fails
|
||||
*/
|
||||
getRoadmapFeatureHandoff(roadmapId, milestoneId, featureId) {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
// Validate milestone exists and belongs to roadmap
|
||||
const milestone = this.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${milestoneId} not found`);
|
||||
}
|
||||
if (milestone.roadmapId !== roadmapId) {
|
||||
throw new Error(`Milestone ${milestoneId} does not belong to roadmap ${roadmapId}`);
|
||||
}
|
||||
// Validate feature exists and belongs to milestone
|
||||
const feature = this.getFeature(featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
if (feature.milestoneId !== milestoneId) {
|
||||
throw new Error(`Feature ${featureId} does not belong to milestone ${milestoneId}`);
|
||||
}
|
||||
// Build the source reference with ordering context
|
||||
const source = {
|
||||
roadmapId: roadmap.id,
|
||||
milestoneId: milestone.id,
|
||||
featureId: feature.id,
|
||||
roadmapTitle: roadmap.title,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneOrderIndex: milestone.orderIndex,
|
||||
featureOrderIndex: feature.orderIndex,
|
||||
};
|
||||
return {
|
||||
source,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Get a mission planning handoff payload for a roadmap.
|
||||
*
|
||||
* Alias for getRoadmapMissionHandoff() for API consistency.
|
||||
* Converts the roadmap into a mission planning structure while preserving
|
||||
* source IDs and deterministic order.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The mission planning handoff payload
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getMissionPlanningHandoff(roadmapId) {
|
||||
return this.getRoadmapMissionHandoff(roadmapId);
|
||||
}
|
||||
/**
|
||||
* List all task planning handoff payloads for a roadmap.
|
||||
*
|
||||
* Returns a flat list of all feature handoffs in deterministic order
|
||||
* (milestone order index, then feature order index).
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns Array of task planning handoff payloads for all features
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
listFeatureTaskPlanningHandoffs(roadmapId) {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
const handoffs = [];
|
||||
for (const milestone of milestones) {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
for (const feature of features) {
|
||||
const source = {
|
||||
roadmapId: roadmap.id,
|
||||
milestoneId: milestone.id,
|
||||
featureId: feature.id,
|
||||
roadmapTitle: roadmap.title,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneOrderIndex: milestone.orderIndex,
|
||||
featureOrderIndex: feature.orderIndex,
|
||||
};
|
||||
handoffs.push({
|
||||
source,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
return handoffs;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=roadmap-store.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -112,6 +112,47 @@ export class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
|
||||
constructor(private db: Database) {
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user