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:
4
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts
vendored
Normal file
4
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { PluginRouteDefinition } from "@fusion/core";
|
||||
export declare function createRoadmapPluginRoutes(): PluginRouteDefinition[];
|
||||
export { SUGGESTION_TIMEOUT_MS };
|
||||
//# sourceMappingURL=roadmap-routes.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"roadmap-routes.d.ts","sourceRoot":"","sources":["roadmap-routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,qBAAqB,EAAuB,MAAM,cAAc,CAAC;AAiG9F,wBAAgB,yBAAyB,IAAI,qBAAqB,EAAE,CAyRnE;AAED,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
|
||||
361
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js
Normal file
361
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js
Normal file
@@ -0,0 +1,361 @@
|
||||
import { RoadmapStore } from "../store/roadmap-store.js";
|
||||
import { generateFeatureSuggestions, generateMilestoneSuggestions, ParseError as SuggestionParseError, ServiceUnavailableError as SuggestionServiceUnavailableError, validateFeatureSuggestionInput, validateSuggestionInput, ValidationError as SuggestionValidationError, } from "./roadmap-suggestions.js";
|
||||
const roadmapStoreCache = new WeakMap();
|
||||
function getRoadmapStore(ctx) {
|
||||
const taskStoreWithRoadmaps = ctx.taskStore;
|
||||
if (typeof taskStoreWithRoadmaps.getRoadmapStore === "function") {
|
||||
return taskStoreWithRoadmaps.getRoadmapStore();
|
||||
}
|
||||
const key = ctx.taskStore;
|
||||
const cached = roadmapStoreCache.get(key);
|
||||
if (cached)
|
||||
return cached;
|
||||
const store = new RoadmapStore(ctx.taskStore.getDatabase());
|
||||
roadmapStoreCache.set(key, store);
|
||||
return store;
|
||||
}
|
||||
function asRequest(req) {
|
||||
return req;
|
||||
}
|
||||
function badRequest(message) {
|
||||
return { status: 400, body: { error: message } };
|
||||
}
|
||||
function notFound(message) {
|
||||
return { status: 404, body: { error: message } };
|
||||
}
|
||||
function serverError(message) {
|
||||
return { status: 500, body: { error: message } };
|
||||
}
|
||||
function noContent() {
|
||||
return { status: 204 };
|
||||
}
|
||||
function routeHandler(handler) {
|
||||
return async (req, ctx) => {
|
||||
const roadmapStore = getRoadmapStore(ctx);
|
||||
try {
|
||||
return await handler(asRequest(req), ctx, roadmapStore);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error && error.message.toLowerCase().includes("not found")) {
|
||||
return notFound(error.message);
|
||||
}
|
||||
return serverError(error instanceof Error ? error.message : "Internal server error");
|
||||
}
|
||||
};
|
||||
}
|
||||
function validateTitle(title) {
|
||||
if (!title || typeof title !== "string" || !title.trim()) {
|
||||
throw new Error("title is required");
|
||||
}
|
||||
if (title.length > 200) {
|
||||
throw new Error("title must not exceed 200 characters");
|
||||
}
|
||||
return title.trim();
|
||||
}
|
||||
function validateDescription(desc) {
|
||||
if (desc === undefined || desc === null)
|
||||
return undefined;
|
||||
if (typeof desc !== "string") {
|
||||
throw new Error("description must be a string");
|
||||
}
|
||||
if (desc.length > 5000) {
|
||||
throw new Error("description must not exceed 5000 characters");
|
||||
}
|
||||
return desc.trim() || undefined;
|
||||
}
|
||||
function validateStringArray(arr, fieldName) {
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new Error(`${fieldName} must be an array`);
|
||||
}
|
||||
if (!arr.every((item) => typeof item === "string")) {
|
||||
throw new Error(`${fieldName} must be an array of strings`);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
export function createRoadmapPluginRoutes() {
|
||||
return [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps",
|
||||
handler: routeHandler((_req, _ctx, roadmapStore) => roadmapStore.listRoadmaps()),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
try {
|
||||
return {
|
||||
status: 201,
|
||||
body: roadmapStore.createRoadmap({
|
||||
title: validateTitle(body?.title),
|
||||
description: validateDescription(body?.description),
|
||||
}),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const roadmap = roadmapStore.getRoadmapWithHierarchy(req.params.roadmapId);
|
||||
return roadmap ? roadmap : notFound(`Roadmap ${req.params.roadmapId} not found`);
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/roadmaps/:roadmapId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
try {
|
||||
return roadmapStore.updateRoadmap(req.params.roadmapId, {
|
||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{ method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
roadmapStore.deleteRoadmap(req.params.roadmapId);
|
||||
return noContent();
|
||||
}) },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/:roadmapId/milestones",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
try {
|
||||
return {
|
||||
status: 201,
|
||||
body: roadmapStore.createMilestone(req.params.roadmapId, {
|
||||
title: validateTitle(body?.title),
|
||||
description: validateDescription(body?.description),
|
||||
}),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/:roadmapId/milestones/reorder",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
roadmapStore.reorderMilestones({ roadmapId: req.params.roadmapId, orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") });
|
||||
return noContent();
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/roadmaps/milestones/:milestoneId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
try {
|
||||
return roadmapStore.updateMilestone(req.params.milestoneId, {
|
||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{ method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
roadmapStore.deleteMilestone(req.params.milestoneId);
|
||||
return noContent();
|
||||
}) },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/milestones/:milestoneId/features",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
try {
|
||||
return {
|
||||
status: 201,
|
||||
body: roadmapStore.createFeature(req.params.milestoneId, {
|
||||
title: validateTitle(body?.title),
|
||||
description: validateDescription(body?.description),
|
||||
}),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/milestones/:milestoneId/features/reorder",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
||||
if (!milestone)
|
||||
return notFound(`Milestone ${req.params.milestoneId} not found`);
|
||||
roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: req.params.milestoneId, orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") });
|
||||
return noContent();
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/roadmaps/features/:featureId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
try {
|
||||
return roadmapStore.updateFeature(req.params.featureId, {
|
||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{ method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
roadmapStore.deleteFeature(req.params.featureId);
|
||||
return noContent();
|
||||
}) },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/features/:featureId/move",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body;
|
||||
if (!body?.targetMilestoneId)
|
||||
return badRequest("targetMilestoneId is required");
|
||||
if (typeof body.targetIndex !== "number")
|
||||
return badRequest("targetIndex must be a number");
|
||||
const feature = roadmapStore.getFeature(req.params.featureId);
|
||||
if (!feature)
|
||||
return notFound(`Feature ${req.params.featureId} not found`);
|
||||
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
|
||||
if (!fromMilestone)
|
||||
return notFound(`Source milestone ${feature.milestoneId} not found`);
|
||||
const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId);
|
||||
if (!toMilestone)
|
||||
return notFound(`Target milestone ${body.targetMilestoneId} not found`);
|
||||
roadmapStore.moveFeature({
|
||||
roadmapId: fromMilestone.roadmapId,
|
||||
featureId: req.params.featureId,
|
||||
fromMilestoneId: feature.milestoneId,
|
||||
toMilestoneId: body.targetMilestoneId,
|
||||
targetOrderIndex: body.targetIndex,
|
||||
});
|
||||
return noContent();
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/:roadmapId/suggestions/milestones",
|
||||
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||
const roadmap = roadmapStore.getRoadmap(req.params.roadmapId);
|
||||
if (!roadmap)
|
||||
return notFound(`Roadmap ${req.params.roadmapId} not found`);
|
||||
try {
|
||||
validateSuggestionInput(req.body);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SuggestionValidationError)
|
||||
return badRequest(error.message);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const body = req.body;
|
||||
const suggestions = await generateMilestoneSuggestions(body.goalPrompt, body.count, ctx.taskStore.getRootDir(), undefined, undefined, ctx.createAiSession);
|
||||
return { suggestions };
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SuggestionParseError)
|
||||
return serverError(error.message);
|
||||
if (error instanceof SuggestionServiceUnavailableError) {
|
||||
return { status: 503, body: { error: error.message } };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/milestones/:milestoneId/suggestions/features",
|
||||
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
||||
if (!milestone)
|
||||
return notFound(`Milestone ${req.params.milestoneId} not found`);
|
||||
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
|
||||
if (!roadmap)
|
||||
return notFound(`Roadmap ${milestone.roadmapId} not found`);
|
||||
try {
|
||||
validateFeatureSuggestionInput(req.body);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SuggestionValidationError)
|
||||
return badRequest(error.message);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const body = req.body;
|
||||
const suggestions = await generateFeatureSuggestions({
|
||||
roadmapTitle: roadmap.title,
|
||||
roadmapDescription: roadmap.description,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneDescription: milestone.description,
|
||||
existingFeatureTitles: roadmapStore.listFeatures(milestone.id).map((feature) => feature.title),
|
||||
}, body.count, body.prompt, ctx.taskStore.getRootDir(), undefined, undefined, ctx.createAiSession);
|
||||
return { suggestions };
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SuggestionParseError)
|
||||
return serverError(error.message);
|
||||
if (error instanceof SuggestionServiceUnavailableError) {
|
||||
return { status: 503, body: { error: error.message } };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/export",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapExport(req.params.roadmapId)),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/handoff",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => ({
|
||||
mission: roadmapStore.getMissionPlanningHandoff(req.params.roadmapId),
|
||||
features: roadmapStore.listFeatureTaskPlanningHandoffs(req.params.roadmapId),
|
||||
})),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/handoff/mission",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getMissionPlanningHandoff(req.params.roadmapId)),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapFeatureHandoff(req.params.roadmapId, req.params.milestoneId, req.params.featureId)),
|
||||
},
|
||||
];
|
||||
}
|
||||
export { SUGGESTION_TIMEOUT_MS } from "./roadmap-suggestions.js";
|
||||
//# sourceMappingURL=roadmap-routes.js.map
|
||||
File diff suppressed because one or more lines are too long
381
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts
Normal file
381
plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
|
||||
import type { Request } from "express";
|
||||
import { RoadmapStore } from "../store/roadmap-store.js";
|
||||
import {
|
||||
generateFeatureSuggestions,
|
||||
generateMilestoneSuggestions,
|
||||
ParseError as SuggestionParseError,
|
||||
ServiceUnavailableError as SuggestionServiceUnavailableError,
|
||||
validateFeatureSuggestionInput,
|
||||
validateSuggestionInput,
|
||||
ValidationError as SuggestionValidationError,
|
||||
} from "./roadmap-suggestions.js";
|
||||
|
||||
const roadmapStoreCache = new WeakMap<object, RoadmapStore>();
|
||||
|
||||
function getRoadmapStore(ctx: PluginContext): RoadmapStore {
|
||||
const taskStoreWithRoadmaps = ctx.taskStore as PluginContext["taskStore"] & {
|
||||
getRoadmapStore?: () => RoadmapStore;
|
||||
};
|
||||
|
||||
if (typeof taskStoreWithRoadmaps.getRoadmapStore === "function") {
|
||||
return taskStoreWithRoadmaps.getRoadmapStore();
|
||||
}
|
||||
|
||||
const key = ctx.taskStore as object;
|
||||
const cached = roadmapStoreCache.get(key);
|
||||
if (cached) return cached;
|
||||
const store = new RoadmapStore(ctx.taskStore.getDatabase());
|
||||
roadmapStoreCache.set(key, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
function asRequest(req: unknown): Request {
|
||||
return req as Request;
|
||||
}
|
||||
|
||||
function badRequest(message: string): PluginRouteResponse {
|
||||
return { status: 400, body: { error: message } };
|
||||
}
|
||||
|
||||
function notFound(message: string): PluginRouteResponse {
|
||||
return { status: 404, body: { error: message } };
|
||||
}
|
||||
|
||||
function serverError(message: string): PluginRouteResponse {
|
||||
return { status: 500, body: { error: message } };
|
||||
}
|
||||
|
||||
function noContent(): PluginRouteResponse {
|
||||
return { status: 204 };
|
||||
}
|
||||
|
||||
function routeHandler<T>(handler: (req: Request, ctx: PluginContext, roadmapStore: RoadmapStore) => Promise<T | PluginRouteResponse> | T | PluginRouteResponse) {
|
||||
return async (req: unknown, ctx: PluginContext): Promise<T | PluginRouteResponse> => {
|
||||
const roadmapStore = getRoadmapStore(ctx);
|
||||
try {
|
||||
return await handler(asRequest(req), ctx, roadmapStore);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.toLowerCase().includes("not found")) {
|
||||
return notFound(error.message);
|
||||
}
|
||||
return serverError(error instanceof Error ? error.message : "Internal server error");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function validateTitle(title: unknown): string {
|
||||
if (!title || typeof title !== "string" || !title.trim()) {
|
||||
throw new Error("title is required");
|
||||
}
|
||||
if (title.length > 200) {
|
||||
throw new Error("title must not exceed 200 characters");
|
||||
}
|
||||
return title.trim();
|
||||
}
|
||||
|
||||
function validateDescription(desc: unknown): string | undefined {
|
||||
if (desc === undefined || desc === null) return undefined;
|
||||
if (typeof desc !== "string") {
|
||||
throw new Error("description must be a string");
|
||||
}
|
||||
if (desc.length > 5000) {
|
||||
throw new Error("description must not exceed 5000 characters");
|
||||
}
|
||||
return desc.trim() || undefined;
|
||||
}
|
||||
|
||||
function validateStringArray(arr: unknown, fieldName: string): string[] {
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new Error(`${fieldName} must be an array`);
|
||||
}
|
||||
if (!arr.every((item) => typeof item === "string")) {
|
||||
throw new Error(`${fieldName} must be an array of strings`);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
||||
return [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps",
|
||||
handler: routeHandler((_req, _ctx, roadmapStore) => roadmapStore.listRoadmaps()),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { title: string; description?: string };
|
||||
try {
|
||||
return {
|
||||
status: 201,
|
||||
body: roadmapStore.createRoadmap({
|
||||
title: validateTitle(body?.title),
|
||||
description: validateDescription(body?.description),
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const roadmap = roadmapStore.getRoadmapWithHierarchy(req.params.roadmapId);
|
||||
return roadmap ? roadmap : notFound(`Roadmap ${req.params.roadmapId} not found`);
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/roadmaps/:roadmapId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { title?: string; description?: string };
|
||||
try {
|
||||
return roadmapStore.updateRoadmap(req.params.roadmapId, {
|
||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{ method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
roadmapStore.deleteRoadmap(req.params.roadmapId);
|
||||
return noContent();
|
||||
}) },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/:roadmapId/milestones",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { title: string; description?: string };
|
||||
try {
|
||||
return {
|
||||
status: 201,
|
||||
body: roadmapStore.createMilestone(req.params.roadmapId, {
|
||||
title: validateTitle(body?.title),
|
||||
description: validateDescription(body?.description),
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/:roadmapId/milestones/reorder",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
try {
|
||||
const body = req.body as { orderedMilestoneIds: string[] };
|
||||
roadmapStore.reorderMilestones({ roadmapId: req.params.roadmapId, orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") });
|
||||
return noContent();
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/roadmaps/milestones/:milestoneId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { title?: string; description?: string };
|
||||
try {
|
||||
return roadmapStore.updateMilestone(req.params.milestoneId, {
|
||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{ method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
roadmapStore.deleteMilestone(req.params.milestoneId);
|
||||
return noContent();
|
||||
}) },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/milestones/:milestoneId/features",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { title: string; description?: string };
|
||||
try {
|
||||
return {
|
||||
status: 201,
|
||||
body: roadmapStore.createFeature(req.params.milestoneId, {
|
||||
title: validateTitle(body?.title),
|
||||
description: validateDescription(body?.description),
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/milestones/:milestoneId/features/reorder",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
try {
|
||||
const body = req.body as { orderedFeatureIds: string[] };
|
||||
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
||||
if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`);
|
||||
roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: req.params.milestoneId, orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") });
|
||||
return noContent();
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/roadmaps/features/:featureId",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { title?: string; description?: string };
|
||||
try {
|
||||
return roadmapStore.updateFeature(req.params.featureId, {
|
||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||
description: body.description !== undefined ? validateDescription(body.description) : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||
}
|
||||
}),
|
||||
},
|
||||
{ method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
roadmapStore.deleteFeature(req.params.featureId);
|
||||
return noContent();
|
||||
}) },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/features/:featureId/move",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||
const body = req.body as { targetMilestoneId: string; targetIndex: number };
|
||||
if (!body?.targetMilestoneId) return badRequest("targetMilestoneId is required");
|
||||
if (typeof body.targetIndex !== "number") return badRequest("targetIndex must be a number");
|
||||
|
||||
const feature = roadmapStore.getFeature(req.params.featureId);
|
||||
if (!feature) return notFound(`Feature ${req.params.featureId} not found`);
|
||||
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
|
||||
if (!fromMilestone) return notFound(`Source milestone ${feature.milestoneId} not found`);
|
||||
const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId);
|
||||
if (!toMilestone) return notFound(`Target milestone ${body.targetMilestoneId} not found`);
|
||||
|
||||
roadmapStore.moveFeature({
|
||||
roadmapId: fromMilestone.roadmapId,
|
||||
featureId: req.params.featureId,
|
||||
fromMilestoneId: feature.milestoneId,
|
||||
toMilestoneId: body.targetMilestoneId,
|
||||
targetOrderIndex: body.targetIndex,
|
||||
});
|
||||
|
||||
return noContent();
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/:roadmapId/suggestions/milestones",
|
||||
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||
const roadmap = roadmapStore.getRoadmap(req.params.roadmapId);
|
||||
if (!roadmap) return notFound(`Roadmap ${req.params.roadmapId} not found`);
|
||||
|
||||
try {
|
||||
validateSuggestionInput(req.body);
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionValidationError) return badRequest(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = req.body as { goalPrompt: string; count?: number };
|
||||
const suggestions = await generateMilestoneSuggestions(
|
||||
body.goalPrompt,
|
||||
body.count,
|
||||
ctx.taskStore.getRootDir(),
|
||||
undefined,
|
||||
undefined,
|
||||
ctx.createAiSession,
|
||||
);
|
||||
return { suggestions };
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionParseError) return serverError(error.message);
|
||||
if (error instanceof SuggestionServiceUnavailableError) {
|
||||
return { status: 503, body: { error: error.message } };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/roadmaps/milestones/:milestoneId/suggestions/features",
|
||||
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
||||
if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`);
|
||||
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
|
||||
if (!roadmap) return notFound(`Roadmap ${milestone.roadmapId} not found`);
|
||||
|
||||
try {
|
||||
validateFeatureSuggestionInput(req.body);
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionValidationError) return badRequest(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = req.body as { prompt?: string; count?: number };
|
||||
const suggestions = await generateFeatureSuggestions(
|
||||
{
|
||||
roadmapTitle: roadmap.title,
|
||||
roadmapDescription: roadmap.description,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneDescription: milestone.description,
|
||||
existingFeatureTitles: roadmapStore.listFeatures(milestone.id).map((feature) => feature.title),
|
||||
},
|
||||
body.count,
|
||||
body.prompt,
|
||||
ctx.taskStore.getRootDir(),
|
||||
undefined,
|
||||
undefined,
|
||||
ctx.createAiSession,
|
||||
);
|
||||
return { suggestions };
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionParseError) return serverError(error.message);
|
||||
if (error instanceof SuggestionServiceUnavailableError) {
|
||||
return { status: 503, body: { error: error.message } };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/export",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapExport(req.params.roadmapId)),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/handoff",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => ({
|
||||
mission: roadmapStore.getMissionPlanningHandoff(req.params.roadmapId),
|
||||
features: roadmapStore.listFeatureTaskPlanningHandoffs(req.params.roadmapId),
|
||||
})),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/handoff/mission",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getMissionPlanningHandoff(req.params.roadmapId)),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task",
|
||||
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapFeatureHandoff(req.params.roadmapId, req.params.milestoneId, req.params.featureId)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export { SUGGESTION_TIMEOUT_MS };
|
||||
68
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts
vendored
Normal file
68
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { CreateAiSessionFactory } from "@fusion/core";
|
||||
type LegacyCreateFnAgent = (options: {
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
tools?: "coding" | "readonly";
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
onThinking?: () => void;
|
||||
onText?: () => void;
|
||||
}) => Promise<{
|
||||
session: {
|
||||
prompt(text: string): Promise<void>;
|
||||
state: {
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content?: string | Array<{
|
||||
type: string;
|
||||
text: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
dispose?: () => void;
|
||||
};
|
||||
}>;
|
||||
export interface GenerateMilestoneSuggestionsInput {
|
||||
goalPrompt: string;
|
||||
count?: number;
|
||||
}
|
||||
export interface MilestoneSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
export declare const MILESTONE_SUGGESTION_SYSTEM_PROMPT = "You are a milestone planning assistant for a product roadmap system.\n\nYour job is to suggest logical milestones that would help achieve a user's roadmap goal.\n\n## Guidelines\n\n1. **Think about phases**: Break the goal into logical phases\n2. **Use clear titles**: Milestone titles should be concise and descriptive\n3. **Add context**: Include a brief description explaining what this milestone encompasses\n4. **Order matters**: List milestones in the order they should be completed\n5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks\n\n## Output Format\n\nRespond with ONLY a valid JSON array of milestone suggestions.";
|
||||
export declare const SUGGESTION_TIMEOUT_MS = 120000;
|
||||
export declare function validateSuggestionInput(input: unknown): asserts input is GenerateMilestoneSuggestionsInput;
|
||||
export declare function generateMilestoneSuggestions(goalPrompt: string, count?: number, rootDir?: string, modelProvider?: string, modelId?: string, createAiSession?: CreateAiSessionFactory): Promise<MilestoneSuggestion[]>;
|
||||
export interface GenerateFeatureSuggestionsInput {
|
||||
prompt?: string;
|
||||
count?: number;
|
||||
}
|
||||
export interface FeatureSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
export interface FeatureSuggestionContext {
|
||||
roadmapTitle: string;
|
||||
roadmapDescription?: string;
|
||||
milestoneTitle: string;
|
||||
milestoneDescription?: string;
|
||||
existingFeatureTitles: string[];
|
||||
}
|
||||
export declare const FEATURE_SUGGESTION_SYSTEM_PROMPT = "You are a feature planning assistant for a product roadmap system.";
|
||||
export declare function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput;
|
||||
export declare function generateFeatureSuggestions(context: FeatureSuggestionContext, count?: number, prompt?: string, rootDir?: string, modelProvider?: string, modelId?: string, createAiSession?: CreateAiSessionFactory): Promise<FeatureSuggestion[]>;
|
||||
export declare class ValidationError extends Error {
|
||||
constructor(message: string);
|
||||
}
|
||||
export declare class ParseError extends Error {
|
||||
constructor(message: string);
|
||||
}
|
||||
export declare class ServiceUnavailableError extends Error {
|
||||
constructor(message: string);
|
||||
}
|
||||
export declare function __resetSuggestionState(): void;
|
||||
export declare function __setCreateFnAgent(mock: LegacyCreateFnAgent | undefined): void;
|
||||
export declare function __setCreateAiSessionFactory(mock: CreateAiSessionFactory | undefined): void;
|
||||
export {};
|
||||
//# sourceMappingURL=roadmap-suggestions.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"roadmap-suggestions.d.ts","sourceRoot":"","sources":["roadmap-suggestions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AAE3D,KAAK,mBAAmB,GAAG,CAAC,OAAO,EAAE;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,QAAQ,GAAG,UAAU,CAAC;IAC9B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;CACrB,KAAK,OAAO,CAAC;IACZ,OAAO,EAAE;QACP,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QACpC,KAAK,EAAE;YAAE,QAAQ,EAAE,KAAK,CAAC;gBAAE,IAAI,EAAE,MAAM,CAAC;gBAAC,OAAO,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;oBAAE,IAAI,EAAE,MAAM,CAAC;oBAAC,IAAI,EAAE,MAAM,CAAA;iBAAE,CAAC,CAAA;aAAE,CAAC,CAAA;SAAE,CAAC;QACvG,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;KACtB,CAAC;CACH,CAAC,CAAC;AAIH,MAAM,WAAW,iCAAiC;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,eAAO,MAAM,kCAAkC,6oBAcgB,CAAC;AAGhE,eAAO,MAAM,qBAAqB,SAAU,CAAC;AAM7C,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,iCAAiC,CAiB1G;AAyHD,wBAAsB,4BAA4B,CAChD,UAAU,EAAE,MAAM,EAClB,KAAK,GAAE,MAAiC,EACxC,OAAO,CAAC,EAAE,MAAM,EAChB,aAAa,CAAC,EAAE,MAAM,EACtB,OAAO,CAAC,EAAE,MAAM,EAChB,eAAe,CAAC,EAAE,sBAAsB,GACvC,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAmDhC;AAED,MAAM,WAAW,+BAA+B;IAC9C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,cAAc,EAAE,MAAM,CAAC;IACvB,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,qBAAqB,EAAE,MAAM,EAAE,CAAC;CACjC;AAED,eAAO,MAAM,gCAAgC,uEAAuE,CAAC;AAIrH,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,+BAA+B,CAa/G;AAmBD,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,wBAAwB,EACjC,KAAK,GAAE,MAAiC,EACxC,MAAM,CAAC,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,MAAM,EAChB,aAAa,CAAC,EAAE,MAAM,EACtB,OAAO,CAAC,EAAE,MAAM,EAChB,eAAe,CAAC,EAAE,sBAAsB,GACvC,OAAO,CAAC,iBAAiB,EAAE,CAAC,CA8B9B;AAED,qBAAa,eAAgB,SAAQ,KAAK;gBAC5B,OAAO,EAAE,MAAM;CAI5B;AAED,qBAAa,UAAW,SAAQ,KAAK;gBACvB,OAAO,EAAE,MAAM;CAI5B;AAED,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,OAAO,EAAE,MAAM;CAI5B;AAED,wBAAgB,sBAAsB,IAAI,IAAI,CAE7C;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,mBAAmB,GAAG,SAAS,GAAG,IAAI,CAM9E;AAED,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,sBAAsB,GAAG,SAAS,GAAG,IAAI,CAE1F"}
|
||||
300
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js
Normal file
300
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js
Normal file
@@ -0,0 +1,300 @@
|
||||
let injectedCreateAiSession;
|
||||
export const MILESTONE_SUGGESTION_SYSTEM_PROMPT = `You are a milestone planning assistant for a product roadmap system.
|
||||
|
||||
Your job is to suggest logical milestones that would help achieve a user's roadmap goal.
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. **Think about phases**: Break the goal into logical phases
|
||||
2. **Use clear titles**: Milestone titles should be concise and descriptive
|
||||
3. **Add context**: Include a brief description explaining what this milestone encompasses
|
||||
4. **Order matters**: List milestones in the order they should be completed
|
||||
5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond with ONLY a valid JSON array of milestone suggestions.`;
|
||||
const MAX_GOAL_PROMPT_LENGTH = 4000;
|
||||
export const SUGGESTION_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_SUGGESTION_COUNT = 5;
|
||||
const MAX_SUGGESTION_COUNT = 10;
|
||||
const MIN_SUGGESTION_COUNT = 1;
|
||||
const MAX_PARSE_RETRIES = 1;
|
||||
export function validateSuggestionInput(input) {
|
||||
if (!input || typeof input !== "object") {
|
||||
throw new ValidationError("Request body must be an object");
|
||||
}
|
||||
const { goalPrompt, count } = input;
|
||||
if (typeof goalPrompt !== "string" || !goalPrompt.trim()) {
|
||||
throw new ValidationError("goalPrompt is required and must be a non-empty string");
|
||||
}
|
||||
if (goalPrompt.length > MAX_GOAL_PROMPT_LENGTH) {
|
||||
throw new ValidationError(`goalPrompt exceeds maximum length of ${MAX_GOAL_PROMPT_LENGTH} characters`);
|
||||
}
|
||||
if (count !== undefined) {
|
||||
if (typeof count !== "number" || !Number.isInteger(count))
|
||||
throw new ValidationError("count must be an integer");
|
||||
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||
throw new ValidationError(`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function extractJsonCandidate(text) {
|
||||
if (!text || !text.trim())
|
||||
return null;
|
||||
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||||
const source = codeBlockMatch?.[1]?.trim() || text.trim();
|
||||
const startIndex = source.indexOf("[");
|
||||
if (startIndex < 0)
|
||||
return null;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let index = startIndex; index < source.length; index++) {
|
||||
const char = source[index];
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
}
|
||||
else if (char === "\\") {
|
||||
escaped = true;
|
||||
}
|
||||
else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "[")
|
||||
depth++;
|
||||
if (char === "]") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return source.slice(startIndex, index + 1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return source.slice(startIndex).trim();
|
||||
}
|
||||
function repairJson(text) {
|
||||
let repaired = text.replace(/,\s*([}\]])/g, "$1");
|
||||
let depthBraces = 0;
|
||||
let depthBrackets = 0;
|
||||
for (const ch of repaired) {
|
||||
if (ch === "{")
|
||||
depthBraces++;
|
||||
if (ch === "}")
|
||||
depthBraces--;
|
||||
if (ch === "[")
|
||||
depthBrackets++;
|
||||
if (ch === "]")
|
||||
depthBrackets--;
|
||||
}
|
||||
repaired += "]".repeat(Math.max(0, depthBrackets));
|
||||
repaired += "}".repeat(Math.max(0, depthBraces));
|
||||
return repaired;
|
||||
}
|
||||
function parseMilestoneSuggestions(text) {
|
||||
const candidate = extractJsonCandidate(text);
|
||||
if (!candidate)
|
||||
throw new ParseError("AI returned no valid JSON. Please try again.");
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
}
|
||||
catch {
|
||||
parsed = JSON.parse(repairJson(candidate));
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new ParseError("AI response must be a JSON array of milestone suggestions");
|
||||
}
|
||||
const suggestions = [];
|
||||
for (const item of parsed) {
|
||||
if (!item || typeof item !== "object")
|
||||
continue;
|
||||
const row = item;
|
||||
if (typeof row.title !== "string" || !row.title.trim())
|
||||
continue;
|
||||
suggestions.push({
|
||||
title: row.title.trim(),
|
||||
description: typeof row.description === "string" && row.description.trim() ? row.description.trim() : undefined,
|
||||
});
|
||||
}
|
||||
if (suggestions.length === 0)
|
||||
throw new ParseError("AI returned no valid milestone suggestions");
|
||||
return suggestions;
|
||||
}
|
||||
function pickFactory(explicit) {
|
||||
return explicit ?? injectedCreateAiSession;
|
||||
}
|
||||
async function runPrompt(createAiSession, options, prompt) {
|
||||
const agent = await createAiSession(options);
|
||||
await agent.session.prompt(prompt);
|
||||
const lastMessage = agent.session.state.messages
|
||||
.filter((m) => m.role === "assistant")
|
||||
.pop();
|
||||
let text = "";
|
||||
if (lastMessage?.content) {
|
||||
if (typeof lastMessage.content === "string")
|
||||
text = lastMessage.content;
|
||||
else {
|
||||
text = lastMessage.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
return { text, dispose: agent.session.dispose };
|
||||
}
|
||||
export async function generateMilestoneSuggestions(goalPrompt, count = DEFAULT_SUGGESTION_COUNT, rootDir, modelProvider, modelId, createAiSession) {
|
||||
const factory = pickFactory(createAiSession);
|
||||
if (!factory)
|
||||
throw new ServiceUnavailableError("AI service is not available");
|
||||
if (!rootDir)
|
||||
throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
let dispose;
|
||||
try {
|
||||
let response = await runPrompt(factory, {
|
||||
cwd: rootDir,
|
||||
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||
}, `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`);
|
||||
dispose = response.dispose;
|
||||
let suggestions;
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||
try {
|
||||
suggestions = parseMilestoneSuggestions(response.text);
|
||||
break;
|
||||
}
|
||||
catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (attempt === MAX_PARSE_RETRIES)
|
||||
break;
|
||||
response = await runPrompt(factory, {
|
||||
cwd: rootDir,
|
||||
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||
}, "Your previous response could not be parsed as JSON. Respond with only a JSON array.");
|
||||
dispose = response.dispose;
|
||||
}
|
||||
}
|
||||
if (!suggestions) {
|
||||
throw new ParseError(`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message ?? "Unknown error"}`);
|
||||
}
|
||||
return suggestions.slice(0, count);
|
||||
}
|
||||
finally {
|
||||
dispose?.();
|
||||
}
|
||||
})(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
|
||||
]);
|
||||
return result;
|
||||
}
|
||||
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.`;
|
||||
const MAX_FEATURE_PROMPT_LENGTH = 2000;
|
||||
export function validateFeatureSuggestionInput(input) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input))
|
||||
throw new ValidationError("Request body must be an object");
|
||||
const { prompt, count } = input;
|
||||
if (prompt !== undefined) {
|
||||
if (typeof prompt !== "string")
|
||||
throw new ValidationError("prompt must be a string");
|
||||
if (prompt.length > MAX_FEATURE_PROMPT_LENGTH)
|
||||
throw new ValidationError(`prompt exceeds maximum length of ${MAX_FEATURE_PROMPT_LENGTH} characters`);
|
||||
}
|
||||
if (count !== undefined) {
|
||||
if (typeof count !== "number" || !Number.isInteger(count))
|
||||
throw new ValidationError("count must be an integer");
|
||||
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||
throw new ValidationError(`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function buildMilestoneContextString(context) {
|
||||
const lines = [];
|
||||
lines.push(`Roadmap: ${context.roadmapTitle}`);
|
||||
if (context.roadmapDescription)
|
||||
lines.push(`Description: ${context.roadmapDescription}`);
|
||||
lines.push("", `Milestone: ${context.milestoneTitle}`);
|
||||
if (context.milestoneDescription)
|
||||
lines.push(`Description: ${context.milestoneDescription}`);
|
||||
if (context.existingFeatureTitles.length > 0) {
|
||||
lines.push("", "Existing features in this milestone:");
|
||||
for (const title of context.existingFeatureTitles)
|
||||
lines.push(` - ${title}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
function parseFeatureSuggestions(text) {
|
||||
return parseMilestoneSuggestions(text);
|
||||
}
|
||||
export async function generateFeatureSuggestions(context, count = DEFAULT_SUGGESTION_COUNT, prompt, rootDir, modelProvider, modelId, createAiSession) {
|
||||
const factory = pickFactory(createAiSession);
|
||||
if (!factory)
|
||||
throw new ServiceUnavailableError("AI service is not available");
|
||||
if (!rootDir)
|
||||
throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||
const systemPrompt = `${FEATURE_SUGGESTION_SYSTEM_PROMPT}\n\n${buildMilestoneContextString(context)}`;
|
||||
const userMessage = prompt?.trim()
|
||||
? `Please suggest ${count} features for the milestone described above.\n\nAdditional guidance:\n${prompt.trim()}`
|
||||
: `Please suggest ${count} features for the milestone described above.`;
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
const { text, dispose } = await runPrompt(factory, {
|
||||
cwd: rootDir,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||
}, userMessage);
|
||||
try {
|
||||
return parseFeatureSuggestions(text).slice(0, count);
|
||||
}
|
||||
finally {
|
||||
dispose?.();
|
||||
}
|
||||
})(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
|
||||
]);
|
||||
return result;
|
||||
}
|
||||
export class ValidationError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
export class ParseError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "ParseError";
|
||||
}
|
||||
}
|
||||
export class ServiceUnavailableError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "ServiceUnavailableError";
|
||||
}
|
||||
}
|
||||
export function __resetSuggestionState() {
|
||||
injectedCreateAiSession = undefined;
|
||||
}
|
||||
export function __setCreateFnAgent(mock) {
|
||||
if (!mock) {
|
||||
injectedCreateAiSession = undefined;
|
||||
return;
|
||||
}
|
||||
injectedCreateAiSession = async (options) => mock(options);
|
||||
}
|
||||
export function __setCreateAiSessionFactory(mock) {
|
||||
injectedCreateAiSession = mock;
|
||||
}
|
||||
//# sourceMappingURL=roadmap-suggestions.js.map
|
||||
File diff suppressed because one or more lines are too long
381
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts
Normal file
381
plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import type { CreateAiSessionFactory } from "@fusion/core";
|
||||
|
||||
type LegacyCreateFnAgent = (options: {
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
tools?: "coding" | "readonly";
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
onThinking?: () => void;
|
||||
onText?: () => void;
|
||||
}) => Promise<{
|
||||
session: {
|
||||
prompt(text: string): Promise<void>;
|
||||
state: { messages: Array<{ role: string; content?: string | Array<{ type: string; text: string }> }> };
|
||||
dispose?: () => void;
|
||||
};
|
||||
}>;
|
||||
|
||||
let injectedCreateAiSession: CreateAiSessionFactory | undefined;
|
||||
|
||||
export interface GenerateMilestoneSuggestionsInput {
|
||||
goalPrompt: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface MilestoneSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const MILESTONE_SUGGESTION_SYSTEM_PROMPT = `You are a milestone planning assistant for a product roadmap system.
|
||||
|
||||
Your job is to suggest logical milestones that would help achieve a user's roadmap goal.
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. **Think about phases**: Break the goal into logical phases
|
||||
2. **Use clear titles**: Milestone titles should be concise and descriptive
|
||||
3. **Add context**: Include a brief description explaining what this milestone encompasses
|
||||
4. **Order matters**: List milestones in the order they should be completed
|
||||
5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond with ONLY a valid JSON array of milestone suggestions.`;
|
||||
|
||||
const MAX_GOAL_PROMPT_LENGTH = 4000;
|
||||
export const SUGGESTION_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_SUGGESTION_COUNT = 5;
|
||||
const MAX_SUGGESTION_COUNT = 10;
|
||||
const MIN_SUGGESTION_COUNT = 1;
|
||||
const MAX_PARSE_RETRIES = 1;
|
||||
|
||||
export function validateSuggestionInput(input: unknown): asserts input is GenerateMilestoneSuggestionsInput {
|
||||
if (!input || typeof input !== "object") {
|
||||
throw new ValidationError("Request body must be an object");
|
||||
}
|
||||
const { goalPrompt, count } = input as Record<string, unknown>;
|
||||
if (typeof goalPrompt !== "string" || !goalPrompt.trim()) {
|
||||
throw new ValidationError("goalPrompt is required and must be a non-empty string");
|
||||
}
|
||||
if (goalPrompt.length > MAX_GOAL_PROMPT_LENGTH) {
|
||||
throw new ValidationError(`goalPrompt exceeds maximum length of ${MAX_GOAL_PROMPT_LENGTH} characters`);
|
||||
}
|
||||
if (count !== undefined) {
|
||||
if (typeof count !== "number" || !Number.isInteger(count)) throw new ValidationError("count must be an integer");
|
||||
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||
throw new ValidationError(`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractJsonCandidate(text: string): string | null {
|
||||
if (!text || !text.trim()) return null;
|
||||
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||||
const source = codeBlockMatch?.[1]?.trim() || text.trim();
|
||||
|
||||
const startIndex = source.indexOf("[");
|
||||
if (startIndex < 0) return null;
|
||||
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let index = startIndex; index < source.length; index++) {
|
||||
const char = source[index];
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "[") depth++;
|
||||
if (char === "]") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return source.slice(startIndex, index + 1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return source.slice(startIndex).trim();
|
||||
}
|
||||
|
||||
function repairJson(text: string): string {
|
||||
let repaired = text.replace(/,\s*([}\]])/g, "$1");
|
||||
let depthBraces = 0;
|
||||
let depthBrackets = 0;
|
||||
for (const ch of repaired) {
|
||||
if (ch === "{") depthBraces++;
|
||||
if (ch === "}") depthBraces--;
|
||||
if (ch === "[") depthBrackets++;
|
||||
if (ch === "]") depthBrackets--;
|
||||
}
|
||||
repaired += "]".repeat(Math.max(0, depthBrackets));
|
||||
repaired += "}".repeat(Math.max(0, depthBraces));
|
||||
return repaired;
|
||||
}
|
||||
|
||||
function parseMilestoneSuggestions(text: string): MilestoneSuggestion[] {
|
||||
const candidate = extractJsonCandidate(text);
|
||||
if (!candidate) throw new ParseError("AI returned no valid JSON. Please try again.");
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
} catch {
|
||||
parsed = JSON.parse(repairJson(candidate));
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new ParseError("AI response must be a JSON array of milestone suggestions");
|
||||
}
|
||||
|
||||
const suggestions: MilestoneSuggestion[] = [];
|
||||
for (const item of parsed) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
if (typeof row.title !== "string" || !row.title.trim()) continue;
|
||||
suggestions.push({
|
||||
title: row.title.trim(),
|
||||
description: typeof row.description === "string" && row.description.trim() ? row.description.trim() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) throw new ParseError("AI returned no valid milestone suggestions");
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
interface AgentMessage {
|
||||
role: string;
|
||||
content?: string | Array<{ type: string; text: string }>;
|
||||
}
|
||||
|
||||
function pickFactory(explicit?: CreateAiSessionFactory): CreateAiSessionFactory | undefined {
|
||||
return explicit ?? injectedCreateAiSession;
|
||||
}
|
||||
|
||||
async function runPrompt(
|
||||
createAiSession: CreateAiSessionFactory,
|
||||
options: Parameters<CreateAiSessionFactory>[0],
|
||||
prompt: string,
|
||||
): Promise<{ text: string; dispose?: () => void }> {
|
||||
const agent = await createAiSession(options);
|
||||
await agent.session.prompt(prompt);
|
||||
const lastMessage = (agent.session.state.messages as AgentMessage[])
|
||||
.filter((m) => m.role === "assistant")
|
||||
.pop();
|
||||
|
||||
let text = "";
|
||||
if (lastMessage?.content) {
|
||||
if (typeof lastMessage.content === "string") text = lastMessage.content;
|
||||
else {
|
||||
text = lastMessage.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
return { text, dispose: (agent.session as { dispose?: () => void }).dispose };
|
||||
}
|
||||
|
||||
export async function generateMilestoneSuggestions(
|
||||
goalPrompt: string,
|
||||
count: number = DEFAULT_SUGGESTION_COUNT,
|
||||
rootDir?: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
createAiSession?: CreateAiSessionFactory,
|
||||
): Promise<MilestoneSuggestion[]> {
|
||||
const factory = pickFactory(createAiSession);
|
||||
if (!factory) throw new ServiceUnavailableError("AI service is not available");
|
||||
if (!rootDir) throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
let dispose: (() => void) | undefined;
|
||||
try {
|
||||
let response = await runPrompt(factory, {
|
||||
cwd: rootDir,
|
||||
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||
}, `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`);
|
||||
dispose = response.dispose;
|
||||
|
||||
let suggestions: MilestoneSuggestion[] | undefined;
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||
try {
|
||||
suggestions = parseMilestoneSuggestions(response.text);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (attempt === MAX_PARSE_RETRIES) break;
|
||||
response = await runPrompt(factory, {
|
||||
cwd: rootDir,
|
||||
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||
}, "Your previous response could not be parsed as JSON. Respond with only a JSON array.");
|
||||
dispose = response.dispose;
|
||||
}
|
||||
}
|
||||
|
||||
if (!suggestions) {
|
||||
throw new ParseError(`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message ?? "Unknown error"}`);
|
||||
}
|
||||
|
||||
return suggestions.slice(0, count);
|
||||
} finally {
|
||||
dispose?.();
|
||||
}
|
||||
})(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS),
|
||||
),
|
||||
]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface GenerateFeatureSuggestionsInput {
|
||||
prompt?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface FeatureSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface FeatureSuggestionContext {
|
||||
roadmapTitle: string;
|
||||
roadmapDescription?: string;
|
||||
milestoneTitle: string;
|
||||
milestoneDescription?: string;
|
||||
existingFeatureTitles: string[];
|
||||
}
|
||||
|
||||
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.`;
|
||||
|
||||
const MAX_FEATURE_PROMPT_LENGTH = 2000;
|
||||
|
||||
export function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) throw new ValidationError("Request body must be an object");
|
||||
const { prompt, count } = input as Record<string, unknown>;
|
||||
if (prompt !== undefined) {
|
||||
if (typeof prompt !== "string") throw new ValidationError("prompt must be a string");
|
||||
if (prompt.length > MAX_FEATURE_PROMPT_LENGTH) throw new ValidationError(`prompt exceeds maximum length of ${MAX_FEATURE_PROMPT_LENGTH} characters`);
|
||||
}
|
||||
if (count !== undefined) {
|
||||
if (typeof count !== "number" || !Number.isInteger(count)) throw new ValidationError("count must be an integer");
|
||||
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
|
||||
throw new ValidationError(`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildMilestoneContextString(context: FeatureSuggestionContext): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`Roadmap: ${context.roadmapTitle}`);
|
||||
if (context.roadmapDescription) lines.push(`Description: ${context.roadmapDescription}`);
|
||||
lines.push("", `Milestone: ${context.milestoneTitle}`);
|
||||
if (context.milestoneDescription) lines.push(`Description: ${context.milestoneDescription}`);
|
||||
if (context.existingFeatureTitles.length > 0) {
|
||||
lines.push("", "Existing features in this milestone:");
|
||||
for (const title of context.existingFeatureTitles) lines.push(` - ${title}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function parseFeatureSuggestions(text: string): FeatureSuggestion[] {
|
||||
return parseMilestoneSuggestions(text);
|
||||
}
|
||||
|
||||
export async function generateFeatureSuggestions(
|
||||
context: FeatureSuggestionContext,
|
||||
count: number = DEFAULT_SUGGESTION_COUNT,
|
||||
prompt?: string,
|
||||
rootDir?: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
createAiSession?: CreateAiSessionFactory,
|
||||
): Promise<FeatureSuggestion[]> {
|
||||
const factory = pickFactory(createAiSession);
|
||||
if (!factory) throw new ServiceUnavailableError("AI service is not available");
|
||||
if (!rootDir) throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||
|
||||
const systemPrompt = `${FEATURE_SUGGESTION_SYSTEM_PROMPT}\n\n${buildMilestoneContextString(context)}`;
|
||||
const userMessage = prompt?.trim()
|
||||
? `Please suggest ${count} features for the milestone described above.\n\nAdditional guidance:\n${prompt.trim()}`
|
||||
: `Please suggest ${count} features for the milestone described above.`;
|
||||
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
const { text, dispose } = await runPrompt(factory, {
|
||||
cwd: rootDir,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
|
||||
}, userMessage);
|
||||
try {
|
||||
return parseFeatureSuggestions(text).slice(0, count);
|
||||
} finally {
|
||||
dispose?.();
|
||||
}
|
||||
})(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS),
|
||||
),
|
||||
]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ParseError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ServiceUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetSuggestionState(): void {
|
||||
injectedCreateAiSession = undefined;
|
||||
}
|
||||
|
||||
export function __setCreateFnAgent(mock: LegacyCreateFnAgent | undefined): void {
|
||||
if (!mock) {
|
||||
injectedCreateAiSession = undefined;
|
||||
return;
|
||||
}
|
||||
injectedCreateAiSession = async (options) => mock(options);
|
||||
}
|
||||
|
||||
export function __setCreateAiSessionFactory(mock: CreateAiSessionFactory | undefined): void {
|
||||
injectedCreateAiSession = mock;
|
||||
}
|
||||
Reference in New Issue
Block a user