feat(FN-3158): migrate roadmap domain modules to fusion-plugin-roadmap pack
The merge extracts the roadmap domain into a standalone plugin package (`plugins/fusion-plugin-roadmap`), wiring it into the CLI bundle and dashboard scaffold, with new modules for roadmap types, store, ordering, and handoff logic. A secondary change normalizes shell host bootstrap, introducing `She Fusion-Task-Id: FN-3158
This commit is contained in:
@@ -1,32 +1,27 @@
|
||||
# fusion-plugin-roadmap
|
||||
|
||||
`@fusion-plugin-examples/roadmap` is the workspace package that owns the roadmap plugin boundary used by the roadmap migration.
|
||||
`@fusion-plugin-examples/roadmap` is the workspace package for the bundled `fusion-plugin-roadmap` plugin.
|
||||
|
||||
## Plugin identity
|
||||
|
||||
- Manifest id: `fusion-plugin-roadmap`
|
||||
- Package default export: `definePlugin(...)` manifest object
|
||||
- Route namespace: `/api/plugins/fusion-plugin-roadmap/*`
|
||||
- Dashboard view id: `plugin:fusion-plugin-roadmap:roadmaps`
|
||||
|
||||
## Exported roadmap domain surface
|
||||
## Package layout
|
||||
|
||||
The package root exports (re-exported from `@fusion/core`):
|
||||
- `manifest.json` — plugin metadata and dashboard view declaration
|
||||
- `src/index.ts` — plugin definition (`onSchemaInit`, routes, dashboard view metadata)
|
||||
- `src/server/index.ts` — backend server exports
|
||||
- `src/dashboard-view.tsx` — dashboard view entry export
|
||||
- `src/roadmap-types.ts` + `src/store/*` — roadmap domain ownership target (migrated in follow-up steps)
|
||||
|
||||
- Roadmap domain types
|
||||
- Ordering helpers
|
||||
- `normalizeRoadmapMilestoneOrder`
|
||||
- `applyRoadmapMilestoneReorder`
|
||||
- `normalizeRoadmapFeatureOrder`
|
||||
- `applyRoadmapFeatureReorder`
|
||||
- `moveRoadmapFeature`
|
||||
- Handoff mappers
|
||||
- `mapFeatureToTaskHandoff`
|
||||
- `mapRoadmapToMissionHandoff`
|
||||
- `mapRoadmapWithHierarchyToMissionHandoff`
|
||||
- `mapAllFeaturesToTaskHandoffs`
|
||||
- Store exports
|
||||
- `RoadmapStore`
|
||||
- `RoadmapStoreEvents`
|
||||
## Exported surfaces
|
||||
|
||||
## Compatibility boundary
|
||||
- Root export: plugin default + roadmap domain helpers/types
|
||||
- `./server`: roadmap route + AI suggestion service exports
|
||||
- `./dashboard-view`: Roadmaps dashboard view export for host registry wiring
|
||||
|
||||
This task only backfills the package and export surface expected by migration work. Existing `@fusion/core` and dashboard roadmap consumers intentionally remain in place for now; consumer switchover is deferred to later roadmap-plugin migration tasks.
|
||||
## Notes
|
||||
|
||||
The plugin keeps a single canonical ID/path (`fusion-plugin-roadmap`). Do not introduce alternate route namespaces or plugin IDs for this feature.
|
||||
|
||||
16
plugins/fusion-plugin-roadmap/manifest.json
Normal file
16
plugins/fusion-plugin-roadmap/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "fusion-plugin-roadmap",
|
||||
"name": "Roadmaps",
|
||||
"version": "0.1.0",
|
||||
"description": "Standalone roadmap planning plugin",
|
||||
"dashboardViews": [
|
||||
{
|
||||
"viewId": "roadmaps",
|
||||
"label": "Roadmaps",
|
||||
"componentPath": "./dashboard-view",
|
||||
"icon": "Map",
|
||||
"placement": "primary",
|
||||
"order": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,12 +2,20 @@
|
||||
"name": "@fusion-plugin-examples/roadmap",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Roadmap backend plugin for Fusion",
|
||||
"description": "Roadmap plugin package for Fusion",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./src/server/index.ts",
|
||||
"import": "./src/server/index.ts"
|
||||
},
|
||||
"./dashboard-view": {
|
||||
"types": "./src/dashboard-view.ts",
|
||||
"import": "./src/dashboard-view.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -16,6 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
"@fusion/dashboard": "workspace:*",
|
||||
"@fusion/plugin-sdk": "workspace:*",
|
||||
"express": "^5.1.0"
|
||||
},
|
||||
|
||||
3
plugins/fusion-plugin-roadmap/src/RoadmapsViewBridge.ts
Normal file
3
plugins/fusion-plugin-roadmap/src/RoadmapsViewBridge.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function RoadmapsView() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RoadmapStore as CoreRoadmapStore } from "@fusion/core";
|
||||
import plugin, {
|
||||
RoadmapStore,
|
||||
applyRoadmapFeatureReorder,
|
||||
@@ -14,12 +15,33 @@ import plugin, {
|
||||
} from "../index.js";
|
||||
|
||||
describe("fusion-plugin-roadmap package surface", () => {
|
||||
it("keeps manifest and plugin entry metadata aligned", () => {
|
||||
const manifest = JSON.parse(readFileSync(resolve(process.cwd(), "manifest.json"), "utf8")) as {
|
||||
id: string;
|
||||
version: string;
|
||||
dashboardViews?: Array<{ viewId: string }>;
|
||||
};
|
||||
|
||||
expect(plugin.manifest.id).toBe(manifest.id);
|
||||
expect(plugin.manifest.version).toBe(manifest.version);
|
||||
expect(plugin.dashboardViews?.[0]?.viewId).toBe(manifest.dashboardViews?.[0]?.viewId);
|
||||
});
|
||||
|
||||
it("declares expected package exports", () => {
|
||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf8")) as {
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(pkg.exports).toHaveProperty(".");
|
||||
expect(pkg.exports).toHaveProperty("./server");
|
||||
expect(pkg.exports).toHaveProperty("./dashboard-view");
|
||||
});
|
||||
|
||||
it("exports plugin manifest with roadmap id", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-roadmap");
|
||||
});
|
||||
|
||||
it("re-exports roadmap domain symbols", () => {
|
||||
expect(RoadmapStore).toBe(CoreRoadmapStore);
|
||||
expect(typeof normalizeRoadmapMilestoneOrder).toBe("function");
|
||||
expect(typeof applyRoadmapMilestoneReorder).toBe("function");
|
||||
expect(typeof normalizeRoadmapFeatureOrder).toBe("function");
|
||||
|
||||
3
plugins/fusion-plugin-roadmap/src/dashboard-view.ts
Normal file
3
plugins/fusion-plugin-roadmap/src/dashboard-view.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { RoadmapsView } from "./RoadmapsViewBridge.js";
|
||||
|
||||
export { RoadmapsView as RoadmapDashboardView };
|
||||
@@ -1,16 +1,69 @@
|
||||
import type { Database } from "@fusion/core";
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import { createRoadmapPluginRoutes } from "./roadmap-routes.js";
|
||||
|
||||
export function ensureRoadmapSchema(db: Database): void {
|
||||
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);
|
||||
`);
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
manifest: {
|
||||
id: "fusion-plugin-roadmap",
|
||||
name: "Roadmap",
|
||||
name: "Roadmaps",
|
||||
version: "0.1.0",
|
||||
description: "Roadmap domain package for plugin-owned roadmap migration",
|
||||
description: "Standalone roadmap planning plugin",
|
||||
},
|
||||
state: "installed",
|
||||
hooks: {},
|
||||
hooks: {
|
||||
onSchemaInit: ensureRoadmapSchema,
|
||||
},
|
||||
routes: createRoadmapPluginRoutes(),
|
||||
dashboardViews: [
|
||||
{
|
||||
viewId: "roadmaps",
|
||||
label: "Roadmaps",
|
||||
componentPath: "./dashboard-view",
|
||||
icon: "Map",
|
||||
placement: "primary",
|
||||
order: 30,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
@@ -36,8 +89,7 @@ export type {
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
RoadmapMissionPlanningMilestoneHandoff,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
RoadmapStoreEvents,
|
||||
} from "@fusion/core";
|
||||
} from "./roadmap-types.js";
|
||||
|
||||
export {
|
||||
normalizeRoadmapMilestoneOrder,
|
||||
@@ -45,9 +97,17 @@ export {
|
||||
normalizeRoadmapFeatureOrder,
|
||||
applyRoadmapFeatureReorder,
|
||||
moveRoadmapFeature,
|
||||
} from "./store/roadmap-ordering.js";
|
||||
|
||||
export {
|
||||
mapFeatureToTaskHandoff,
|
||||
mapRoadmapToMissionHandoff,
|
||||
mapRoadmapWithHierarchyToMissionHandoff,
|
||||
mapAllFeaturesToTaskHandoffs,
|
||||
RoadmapStore,
|
||||
} from "@fusion/core";
|
||||
} from "./store/roadmap-handoff.js";
|
||||
|
||||
export { RoadmapStore } from "./store/roadmap-store.js";
|
||||
export type { RoadmapStoreEvents } from "./store/roadmap-store.js";
|
||||
|
||||
export { RoadmapDashboardView } from "./dashboard-view.js";
|
||||
export * from "./server/index.js";
|
||||
|
||||
310
plugins/fusion-plugin-roadmap/src/roadmap-types.ts
Normal file
310
plugins/fusion-plugin-roadmap/src/roadmap-types.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Standalone roadmap planning types.
|
||||
*
|
||||
* This model is intentionally separate from the mission hierarchy so roadmap
|
||||
* work can evolve independently of `MissionStore`/`MissionManager`.
|
||||
*
|
||||
* Core ordering invariants:
|
||||
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
|
||||
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
|
||||
* - cross-milestone feature moves must renumber both the source and target
|
||||
* milestone deterministically after the move
|
||||
* - whenever stored order data is incomplete or conflicting, consumers should
|
||||
* repair it using a stable tie-breaker (`createdAt`, then `id`, both ASC)
|
||||
*
|
||||
* These contracts are persistence-agnostic and UI-agnostic. They define the
|
||||
* canonical domain surface that downstream storage, API, and dashboard work use.
|
||||
*
|
||||
* @module roadmap-types
|
||||
*/
|
||||
|
||||
/**
|
||||
* A standalone roadmap container.
|
||||
*
|
||||
* Roadmaps do not reuse mission lifecycle or mission status concepts. They are
|
||||
* lightweight planning artifacts that own ordered milestones.
|
||||
*/
|
||||
export interface Roadmap {
|
||||
/** Unique identifier (for example `RM-01HXYZ...`) */
|
||||
id: string;
|
||||
/** Display title shown in roadmap lists and detail views */
|
||||
title: string;
|
||||
/** Optional long-form planning context for the roadmap */
|
||||
description?: string;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A milestone within a roadmap.
|
||||
*
|
||||
* `orderIndex` is the canonical persisted ordering field. It is always scoped to
|
||||
* the parent roadmap and must remain contiguous + 0-based after reorder flows.
|
||||
*/
|
||||
export interface RoadmapMilestone {
|
||||
/** Unique identifier (for example `RMS-01HXYZ...`) */
|
||||
id: string;
|
||||
/** Parent roadmap ID */
|
||||
roadmapId: string;
|
||||
/** Display title for the milestone */
|
||||
title: string;
|
||||
/** Optional description of the milestone's goals */
|
||||
description?: string;
|
||||
/** 0-based contiguous ordering within the roadmap */
|
||||
orderIndex: number;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A feature within a roadmap milestone.
|
||||
*
|
||||
* `orderIndex` is scoped to the parent milestone. Cross-milestone moves must
|
||||
* update `milestoneId` and then normalize both affected milestone lists back to
|
||||
* contiguous 0-based order.
|
||||
*/
|
||||
export interface RoadmapFeature {
|
||||
/** Unique identifier (for example `RF-01HXYZ...`) */
|
||||
id: string;
|
||||
/** Parent milestone ID */
|
||||
milestoneId: string;
|
||||
/** Display title for the feature */
|
||||
title: string;
|
||||
/** Optional description of the feature's intent */
|
||||
description?: string;
|
||||
/** 0-based contiguous ordering within the parent milestone */
|
||||
orderIndex: number;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── CRUD Input Types ────────────────────────────────────────────────
|
||||
|
||||
/** Input for creating a roadmap. */
|
||||
export interface RoadmapCreateInput {
|
||||
/** Display title of the roadmap (required) */
|
||||
title: string;
|
||||
/** Optional roadmap description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Input for updating roadmap metadata. Ordering is handled by dedicated move/reorder DTOs. */
|
||||
export interface RoadmapUpdateInput {
|
||||
/** Updated display title */
|
||||
title?: string;
|
||||
/** Updated roadmap description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Input for creating a milestone inside a roadmap. */
|
||||
export interface RoadmapMilestoneCreateInput {
|
||||
/** Display title of the milestone (required) */
|
||||
title: string;
|
||||
/** Optional milestone description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Input for updating milestone metadata. Ordering is handled separately. */
|
||||
export interface RoadmapMilestoneUpdateInput {
|
||||
/** Updated milestone title */
|
||||
title?: string;
|
||||
/** Updated milestone description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Input for creating a feature inside a milestone. */
|
||||
export interface RoadmapFeatureCreateInput {
|
||||
/** Display title of the feature (required) */
|
||||
title: string;
|
||||
/** Optional feature description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Input for updating feature metadata. Ordering is handled separately. */
|
||||
export interface RoadmapFeatureUpdateInput {
|
||||
/** Updated feature title */
|
||||
title?: string;
|
||||
/** Updated feature description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Ordering / Move Payload Types ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Explicit reorder payload for milestones within a roadmap.
|
||||
*
|
||||
* `orderedMilestoneIds` must contain the full set of milestone IDs for the
|
||||
* roadmap exactly once. Consumers should reject partial or duplicate lists.
|
||||
*/
|
||||
export interface RoadmapMilestoneReorderInput {
|
||||
/** Roadmap whose milestone sequence is being rewritten */
|
||||
roadmapId: string;
|
||||
/** Complete milestone ID sequence in final order */
|
||||
orderedMilestoneIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit reorder payload for features within a single milestone.
|
||||
*
|
||||
* `orderedFeatureIds` must contain the full set of feature IDs for the milestone
|
||||
* exactly once. The resulting `orderIndex` values must be normalized to 0-based
|
||||
* contiguous order.
|
||||
*/
|
||||
export interface RoadmapFeatureReorderInput {
|
||||
/** Parent roadmap for integrity validation */
|
||||
roadmapId: string;
|
||||
/** Milestone whose internal feature ordering is being rewritten */
|
||||
milestoneId: string;
|
||||
/** Complete feature ID sequence in final order */
|
||||
orderedFeatureIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit move payload for relocating a feature, including cross-milestone moves.
|
||||
*
|
||||
* `targetOrderIndex` is the desired insertion position in the destination
|
||||
* milestone before final normalization. Consumers should clamp out-of-range
|
||||
* values and must deterministically renumber both source and destination
|
||||
* milestones after the move.
|
||||
*/
|
||||
export interface RoadmapFeatureMoveInput {
|
||||
/** Parent roadmap for integrity validation */
|
||||
roadmapId: string;
|
||||
/** Feature being moved */
|
||||
featureId: string;
|
||||
/** Current milestone that owns the feature */
|
||||
fromMilestoneId: string;
|
||||
/** Destination milestone after the move */
|
||||
toMilestoneId: string;
|
||||
/** Requested insertion index in the destination milestone */
|
||||
targetOrderIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a feature move operation after deterministic renumbering.
|
||||
*
|
||||
* `affectedFeatures` contains the canonical post-move feature records for the
|
||||
* source and target milestones. When a feature is moved within the same
|
||||
* milestone, `sourceMilestoneFeatures` and `targetMilestoneFeatures` will be
|
||||
* the same normalized list.
|
||||
*/
|
||||
export interface RoadmapFeatureMoveResult {
|
||||
/** The moved feature after `milestoneId` and `orderIndex` updates */
|
||||
movedFeature: RoadmapFeature;
|
||||
/** Canonical post-move features for the affected milestone scope */
|
||||
affectedFeatures: RoadmapFeature[];
|
||||
/** Canonical feature list for the source milestone after the move */
|
||||
sourceMilestoneFeatures: RoadmapFeature[];
|
||||
/** Canonical feature list for the destination milestone after the move */
|
||||
targetMilestoneFeatures: RoadmapFeature[];
|
||||
}
|
||||
|
||||
// ── Composite Read Models ───────────────────────────────────────────
|
||||
|
||||
/** Milestone with all of its ordered features loaded. */
|
||||
export interface RoadmapMilestoneWithFeatures extends RoadmapMilestone {
|
||||
/** Features belonging to this milestone */
|
||||
features: RoadmapFeature[];
|
||||
}
|
||||
|
||||
/** Full roadmap hierarchy loaded in roadmap → milestone → feature order. */
|
||||
export interface RoadmapWithHierarchy extends Roadmap {
|
||||
/** Ordered milestones with ordered features */
|
||||
milestones: RoadmapMilestoneWithFeatures[];
|
||||
}
|
||||
|
||||
// ── Export / Handoff Contracts ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flat export payload for persistence, APIs, import/export, and sync jobs.
|
||||
*
|
||||
* This shape intentionally keeps entities separate so downstream persistence
|
||||
* layers can upsert by table/collection without first denormalizing a nested
|
||||
* hierarchy.
|
||||
*/
|
||||
export interface RoadmapExportBundle {
|
||||
/** Roadmap being exported */
|
||||
roadmap: Roadmap;
|
||||
/** Ordered milestones for the roadmap */
|
||||
milestones: RoadmapMilestone[];
|
||||
/** Ordered features for the roadmap's milestones */
|
||||
features: RoadmapFeature[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Source metadata carried forward when a roadmap feature is converted into a
|
||||
* task-planning input or other downstream artifact.
|
||||
*/
|
||||
export interface RoadmapFeatureSourceRef {
|
||||
/** Source roadmap ID */
|
||||
roadmapId: string;
|
||||
/** Source milestone ID */
|
||||
milestoneId: string;
|
||||
/** Source feature ID */
|
||||
featureId: string;
|
||||
/** Human-readable roadmap title for prompt context */
|
||||
roadmapTitle: string;
|
||||
/** Human-readable milestone title for prompt context */
|
||||
milestoneTitle: string;
|
||||
/** Canonical milestone order at handoff time */
|
||||
milestoneOrderIndex: number;
|
||||
/** Canonical feature order at handoff time */
|
||||
featureOrderIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handoff payload for converting a single roadmap feature into task planning
|
||||
* flows without coupling the task system to roadmap persistence details.
|
||||
*/
|
||||
export interface RoadmapFeatureTaskPlanningHandoff {
|
||||
/** Source lineage and ordering context */
|
||||
source: RoadmapFeatureSourceRef;
|
||||
/** Title to seed the downstream task or planning prompt */
|
||||
title: string;
|
||||
/** Optional description to seed the downstream task or planning prompt */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Source-preserving milestone payload used for mission conversion handoffs. */
|
||||
export interface RoadmapMissionPlanningMilestoneHandoff {
|
||||
/** Source roadmap milestone ID */
|
||||
sourceMilestoneId: string;
|
||||
/** Canonical milestone title */
|
||||
title: string;
|
||||
/** Optional milestone description */
|
||||
description?: string;
|
||||
/** Canonical milestone ordering within the roadmap */
|
||||
orderIndex: number;
|
||||
/** Ordered roadmap features that belong to this milestone */
|
||||
features: Array<{
|
||||
/** Source roadmap feature ID */
|
||||
sourceFeatureId: string;
|
||||
/** Canonical feature title */
|
||||
title: string;
|
||||
/** Optional feature description */
|
||||
description?: string;
|
||||
/** Canonical feature ordering within the milestone */
|
||||
orderIndex: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handoff payload for converting a standalone roadmap into mission planning
|
||||
* structures while preserving source IDs and deterministic order.
|
||||
*/
|
||||
export interface RoadmapMissionPlanningHandoff {
|
||||
/** Source roadmap ID */
|
||||
sourceRoadmapId: string;
|
||||
/** Canonical roadmap title */
|
||||
title: string;
|
||||
/** Optional roadmap description */
|
||||
description?: string;
|
||||
/** Ordered milestone breakdown captured at handoff time */
|
||||
milestones: RoadmapMissionPlanningMilestoneHandoff[];
|
||||
}
|
||||
1
plugins/fusion-plugin-roadmap/src/server/index.ts
Normal file
1
plugins/fusion-plugin-roadmap/src/server/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createRoadmapPluginRoutes } from "../roadmap-routes.js";
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Tests for roadmap handoff mapping helpers.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
mapFeatureToTaskHandoff,
|
||||
mapRoadmapToMissionHandoff,
|
||||
mapRoadmapWithHierarchyToMissionHandoff,
|
||||
mapAllFeaturesToTaskHandoffs,
|
||||
} from "../roadmap-handoff.js";
|
||||
import { normalizeRoadmapMilestoneOrder } from "../roadmap-ordering.js";
|
||||
import type {
|
||||
Roadmap,
|
||||
RoadmapMilestone,
|
||||
RoadmapFeature,
|
||||
RoadmapWithHierarchy,
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
} from "../../roadmap-types.js";
|
||||
|
||||
// ── Test Fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
function createRoadmap(overrides: Partial<Roadmap> = {}): Roadmap {
|
||||
return {
|
||||
id: "RM-001",
|
||||
title: "Test Roadmap",
|
||||
description: "A test roadmap",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMilestone(id: string, roadmapId: string, orderIndex: number, overrides: Partial<RoadmapMilestone> = {}): RoadmapMilestone {
|
||||
return {
|
||||
id,
|
||||
roadmapId,
|
||||
title: `Milestone ${id}`,
|
||||
description: `Description for ${id}`,
|
||||
orderIndex,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createFeature(id: string, milestoneId: string, orderIndex: number, overrides: Partial<RoadmapFeature> = {}): RoadmapFeature {
|
||||
return {
|
||||
id,
|
||||
milestoneId,
|
||||
title: `Feature ${id}`,
|
||||
description: `Description for ${id}`,
|
||||
orderIndex,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests for mapFeatureToTaskHandoff ─────────────────────────────────────────
|
||||
|
||||
describe("mapFeatureToTaskHandoff", () => {
|
||||
it("maps a feature to a task planning handoff with all fields", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestone = createMilestone("MS-001", "RM-001", 0);
|
||||
const feature = createFeature("F-001", "MS-001", 0);
|
||||
|
||||
const handoff = mapFeatureToTaskHandoff(roadmap, milestone, feature);
|
||||
|
||||
expect(handoff.title).toBe("Feature F-001");
|
||||
expect(handoff.description).toBe("Description for F-001");
|
||||
expect(handoff.source.roadmapId).toBe("RM-001");
|
||||
expect(handoff.source.milestoneId).toBe("MS-001");
|
||||
expect(handoff.source.featureId).toBe("F-001");
|
||||
expect(handoff.source.roadmapTitle).toBe("Test Roadmap");
|
||||
expect(handoff.source.milestoneTitle).toBe("Milestone MS-001");
|
||||
expect(handoff.source.milestoneOrderIndex).toBe(0);
|
||||
expect(handoff.source.featureOrderIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("handles features without descriptions", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestone = createMilestone("MS-001", "RM-001", 0);
|
||||
const feature = createFeature("F-001", "MS-001", 0, { description: undefined });
|
||||
|
||||
const handoff = mapFeatureToTaskHandoff(roadmap, milestone, feature);
|
||||
|
||||
expect(handoff.title).toBe("Feature F-001");
|
||||
expect(handoff.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves exact IDs from source entities", () => {
|
||||
const roadmap = createRoadmap({ id: "RM-SPECIAL-123" });
|
||||
const milestone = createMilestone("RMS-SPECIAL-456", "RM-SPECIAL-123", 5);
|
||||
const feature = createFeature("RF-SPECIAL-789", "RMS-SPECIAL-456", 3);
|
||||
|
||||
const handoff = mapFeatureToTaskHandoff(roadmap, milestone, feature);
|
||||
|
||||
expect(handoff.source.roadmapId).toBe("RM-SPECIAL-123");
|
||||
expect(handoff.source.milestoneId).toBe("RMS-SPECIAL-456");
|
||||
expect(handoff.source.featureId).toBe("RF-SPECIAL-789");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tests for mapRoadmapToMissionHandoff ─────────────────────────────────────
|
||||
|
||||
describe("mapRoadmapToMissionHandoff", () => {
|
||||
it("maps a roadmap with milestones and features to mission handoff", () => {
|
||||
const roadmap = createRoadmap({ title: "Q1 Planning", description: "Quarterly goals" });
|
||||
const milestones = [
|
||||
createMilestone("MS-001", "RM-001", 0, { title: "Phase 1" }),
|
||||
createMilestone("MS-002", "RM-001", 1, { title: "Phase 2" }),
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-001", [
|
||||
createFeature("F-001", "MS-001", 0, { title: "Auth Feature" }),
|
||||
createFeature("F-002", "MS-001", 1, { title: "Dashboard Feature" }),
|
||||
]],
|
||||
["MS-002", [
|
||||
createFeature("F-003", "MS-002", 0, { title: "Reporting Feature" }),
|
||||
]],
|
||||
]);
|
||||
|
||||
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoff.sourceRoadmapId).toBe("RM-001");
|
||||
expect(handoff.title).toBe("Q1 Planning");
|
||||
expect(handoff.description).toBe("Quarterly goals");
|
||||
expect(handoff.milestones).toHaveLength(2);
|
||||
|
||||
// Verify milestone ordering
|
||||
expect(handoff.milestones[0].title).toBe("Phase 1");
|
||||
expect(handoff.milestones[0].orderIndex).toBe(0);
|
||||
expect(handoff.milestones[1].title).toBe("Phase 2");
|
||||
expect(handoff.milestones[1].orderIndex).toBe(1);
|
||||
|
||||
// Verify feature ordering within milestones
|
||||
expect(handoff.milestones[0].features).toHaveLength(2);
|
||||
expect(handoff.milestones[0].features[0].title).toBe("Auth Feature");
|
||||
expect(handoff.milestones[0].features[0].orderIndex).toBe(0);
|
||||
expect(handoff.milestones[0].features[1].title).toBe("Dashboard Feature");
|
||||
expect(handoff.milestones[0].features[1].orderIndex).toBe(1);
|
||||
|
||||
expect(handoff.milestones[1].features).toHaveLength(1);
|
||||
expect(handoff.milestones[1].features[0].title).toBe("Reporting Feature");
|
||||
});
|
||||
|
||||
it("handles empty milestones array", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
|
||||
const handoff = mapRoadmapToMissionHandoff(roadmap, [], featuresByMilestoneId);
|
||||
|
||||
expect(handoff.sourceRoadmapId).toBe("RM-001");
|
||||
expect(handoff.milestones).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles milestones with empty features", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [
|
||||
createMilestone("MS-001", "RM-001", 0),
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
|
||||
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoff.milestones).toHaveLength(1);
|
||||
expect(handoff.milestones[0].features).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("normalizes deterministic ordering when order indices are out of sequence", () => {
|
||||
const roadmap = createRoadmap();
|
||||
// Simulate out-of-sequence order indices
|
||||
const milestones = [
|
||||
createMilestone("MS-001", "RM-001", 10), // Out of sequence
|
||||
createMilestone("MS-002", "RM-001", 5), // Out of sequence
|
||||
createMilestone("MS-003", "RM-001", 20), // Out of sequence
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
|
||||
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
// Should be normalized to 0, 1, 2
|
||||
expect(handoff.milestones[0].orderIndex).toBe(0);
|
||||
expect(handoff.milestones[1].orderIndex).toBe(1);
|
||||
expect(handoff.milestones[2].orderIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tests for mapRoadmapWithHierarchyToMissionHandoff ────────────────────────
|
||||
|
||||
describe("mapRoadmapWithHierarchyToMissionHandoff", () => {
|
||||
it("maps RoadmapWithHierarchy to mission handoff", () => {
|
||||
const roadmapWithHierarchy: RoadmapWithHierarchy = {
|
||||
id: "RM-001",
|
||||
title: "Hierarchy Roadmap",
|
||||
description: "With full hierarchy",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
milestones: [
|
||||
{
|
||||
...createMilestone("MS-001", "RM-001", 0, { title: "Alpha Phase" }),
|
||||
features: [
|
||||
createFeature("F-001", "MS-001", 0, { title: "Alpha Feature 1" }),
|
||||
createFeature("F-002", "MS-001", 1, { title: "Alpha Feature 2" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
...createMilestone("MS-002", "RM-001", 1, { title: "Beta Phase" }),
|
||||
features: [
|
||||
createFeature("F-003", "MS-002", 0, { title: "Beta Feature" }),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const handoff = mapRoadmapWithHierarchyToMissionHandoff(roadmapWithHierarchy);
|
||||
|
||||
expect(handoff.sourceRoadmapId).toBe("RM-001");
|
||||
expect(handoff.title).toBe("Hierarchy Roadmap");
|
||||
expect(handoff.milestones).toHaveLength(2);
|
||||
expect(handoff.milestones[0].title).toBe("Alpha Phase");
|
||||
expect(handoff.milestones[0].features).toHaveLength(2);
|
||||
expect(handoff.milestones[1].title).toBe("Beta Phase");
|
||||
expect(handoff.milestones[1].features).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles empty milestone hierarchy", () => {
|
||||
const roadmapWithHierarchy: RoadmapWithHierarchy = {
|
||||
...createRoadmap(),
|
||||
milestones: [],
|
||||
};
|
||||
|
||||
const handoff = mapRoadmapWithHierarchyToMissionHandoff(roadmapWithHierarchy);
|
||||
|
||||
expect(handoff.milestones).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tests for mapAllFeaturesToTaskHandoffs ────────────────────────────────────
|
||||
|
||||
describe("mapAllFeaturesToTaskHandoffs", () => {
|
||||
it("flattens all features from a roadmap into individual handoffs", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [
|
||||
createMilestone("MS-001", "RM-001", 0),
|
||||
createMilestone("MS-002", "RM-001", 1),
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-001", [
|
||||
createFeature("F-001", "MS-001", 0),
|
||||
createFeature("F-002", "MS-001", 1),
|
||||
]],
|
||||
["MS-002", [
|
||||
createFeature("F-003", "MS-002", 0),
|
||||
]],
|
||||
]);
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs).toHaveLength(3);
|
||||
expect(handoffs[0].source.featureId).toBe("F-001");
|
||||
expect(handoffs[0].source.milestoneOrderIndex).toBe(0);
|
||||
expect(handoffs[0].source.featureOrderIndex).toBe(0);
|
||||
expect(handoffs[1].source.featureId).toBe("F-002");
|
||||
expect(handoffs[1].source.milestoneOrderIndex).toBe(0);
|
||||
expect(handoffs[1].source.featureOrderIndex).toBe(1);
|
||||
expect(handoffs[2].source.featureId).toBe("F-003");
|
||||
expect(handoffs[2].source.milestoneOrderIndex).toBe(1);
|
||||
expect(handoffs[2].source.featureOrderIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty array when no milestones exist", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, [], featuresByMilestoneId);
|
||||
|
||||
expect(handoffs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty array when milestones have no features", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [createMilestone("MS-001", "RM-001", 0)];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves feature titles and descriptions", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [createMilestone("MS-001", "RM-001", 0)];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-001", [
|
||||
createFeature("F-001", "MS-001", 0, { title: "Core Feature", description: "Main functionality" }),
|
||||
createFeature("F-002", "MS-001", 1, { title: "Secondary Feature", description: undefined }),
|
||||
]],
|
||||
]);
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs[0].title).toBe("Core Feature");
|
||||
expect(handoffs[0].description).toBe("Main functionality");
|
||||
expect(handoffs[1].title).toBe("Secondary Feature");
|
||||
expect(handoffs[1].description).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes ordering when feature order indices are out of sequence", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [createMilestone("MS-001", "RM-001", 0)];
|
||||
// Simulate out-of-sequence feature order indices
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-001", [
|
||||
createFeature("F-001", "MS-001", 100),
|
||||
createFeature("F-002", "MS-001", 50),
|
||||
createFeature("F-003", "MS-001", 75),
|
||||
]],
|
||||
]);
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs).toHaveLength(3);
|
||||
// Should be normalized to 0, 1, 2
|
||||
expect(handoffs[0].source.featureOrderIndex).toBe(0);
|
||||
expect(handoffs[1].source.featureOrderIndex).toBe(1);
|
||||
expect(handoffs[2].source.featureOrderIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("skips features from unknown milestone IDs", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [createMilestone("MS-001", "RM-001", 0)];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-001", [createFeature("F-001", "MS-001", 0)]],
|
||||
// MS-999 is not in milestones, so its features should be ignored
|
||||
["MS-999", [createFeature("F-999", "MS-999", 0)]],
|
||||
]);
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs).toHaveLength(1);
|
||||
expect(handoffs[0].source.featureId).toBe("F-001");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Deterministic Ordering Tests ───────────────────────────────────────────────
|
||||
|
||||
describe("deterministic ordering", () => {
|
||||
it("uses stable ordering when order indices are equal", () => {
|
||||
const roadmap = createRoadmap();
|
||||
// Same order index for all milestones - should sort by createdAt then id
|
||||
const rawMilestones = [
|
||||
createMilestone("MS-001", "RM-001", 0, { createdAt: "2024-01-01T00:00:00.000Z" }),
|
||||
createMilestone("MS-002", "RM-001", 0, { createdAt: "2024-01-01T00:00:00.000Z" }),
|
||||
createMilestone("MS-003", "RM-001", 0, { createdAt: "2024-01-02T00:00:00.000Z" }),
|
||||
];
|
||||
// Normalize before passing to handoff function (mirrors store behavior)
|
||||
const milestones = normalizeRoadmapMilestoneOrder(rawMilestones);
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
|
||||
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
// MS-001 and MS-002 have same orderIndex and createdAt, so should sort by id
|
||||
// MS-003 has later createdAt
|
||||
expect(handoff.milestones[0].sourceMilestoneId).toBe("MS-001");
|
||||
expect(handoff.milestones[1].sourceMilestoneId).toBe("MS-002");
|
||||
expect(handoff.milestones[2].sourceMilestoneId).toBe("MS-003");
|
||||
});
|
||||
|
||||
it("produces consistent output across multiple calls with same input", () => {
|
||||
const roadmap = createRoadmap({ id: "RM-STABLE" });
|
||||
const milestones = [
|
||||
createMilestone("MS-001", "RM-STABLE", 1),
|
||||
createMilestone("MS-002", "RM-STABLE", 0),
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-001", [createFeature("F-001", "MS-001", 1)]],
|
||||
["MS-002", [createFeature("F-002", "MS-002", 0)]],
|
||||
]);
|
||||
|
||||
const first = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
const second = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(first.milestones[0].sourceMilestoneId).toBe(second.milestones[0].sourceMilestoneId);
|
||||
expect(first.milestones[1].sourceMilestoneId).toBe(second.milestones[1].sourceMilestoneId);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Source Lineage Preservation Tests ─────────────────────────────────────────
|
||||
|
||||
describe("source lineage preservation", () => {
|
||||
it("preserves roadmap context in all feature handoffs", () => {
|
||||
const roadmap = createRoadmap({ id: "RM-LINEAGE", title: "Lineage Test" });
|
||||
const milestones = [createMilestone("MS-LINEAGE", "RM-LINEAGE", 0)];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-LINEAGE", [createFeature("F-LINEAGE", "MS-LINEAGE", 0)]],
|
||||
]);
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs[0].source.roadmapId).toBe("RM-LINEAGE");
|
||||
expect(handoffs[0].source.roadmapTitle).toBe("Lineage Test");
|
||||
});
|
||||
|
||||
it("preserves milestone context in all feature handoffs", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [
|
||||
createMilestone("MS-ALPHA", "RM-001", 0, { title: "Alpha Milestone" }),
|
||||
createMilestone("MS-BETA", "RM-001", 1, { title: "Beta Milestone" }),
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-ALPHA", [createFeature("F-001", "MS-ALPHA", 0)]],
|
||||
["MS-BETA", [createFeature("F-002", "MS-BETA", 0)]],
|
||||
]);
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs).toHaveLength(2);
|
||||
expect(handoffs[0].source.milestoneId).toBe("MS-ALPHA");
|
||||
expect(handoffs[0].source.milestoneTitle).toBe("Alpha Milestone");
|
||||
expect(handoffs[1].source.milestoneId).toBe("MS-BETA");
|
||||
expect(handoffs[1].source.milestoneTitle).toBe("Beta Milestone");
|
||||
});
|
||||
|
||||
it("mission handoff preserves source IDs on all entities", () => {
|
||||
const roadmap = createRoadmap({ id: "RM-MISSION" });
|
||||
const milestones = [
|
||||
createMilestone("MS-MISSION-1", "RM-MISSION", 0, { title: "First Phase" }),
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-MISSION-1", [
|
||||
createFeature("RF-MISSION-1", "MS-MISSION-1", 0, { title: "Mission Feature" }),
|
||||
]],
|
||||
]);
|
||||
|
||||
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoff.sourceRoadmapId).toBe("RM-MISSION");
|
||||
expect(handoff.milestones[0].sourceMilestoneId).toBe("MS-MISSION-1");
|
||||
expect(handoff.milestones[0].features[0].sourceFeatureId).toBe("RF-MISSION-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyRoadmapFeatureReorder,
|
||||
applyRoadmapMilestoneReorder,
|
||||
moveRoadmapFeature,
|
||||
normalizeRoadmapFeatureOrder,
|
||||
normalizeRoadmapMilestoneOrder,
|
||||
} from "../roadmap-ordering.js";
|
||||
import type { RoadmapFeature, RoadmapMilestone } from "../../roadmap-types.js";
|
||||
|
||||
function createMilestone(
|
||||
id: string,
|
||||
roadmapId: string,
|
||||
orderIndex: number,
|
||||
createdAt: string,
|
||||
): RoadmapMilestone {
|
||||
return {
|
||||
id,
|
||||
roadmapId,
|
||||
title: id,
|
||||
description: `${id} description`,
|
||||
orderIndex,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function createFeature(
|
||||
id: string,
|
||||
milestoneId: string,
|
||||
orderIndex: number,
|
||||
createdAt: string,
|
||||
): RoadmapFeature {
|
||||
return {
|
||||
id,
|
||||
milestoneId,
|
||||
title: id,
|
||||
description: `${id} description`,
|
||||
orderIndex,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe("roadmap-ordering", () => {
|
||||
describe("normalizeRoadmapMilestoneOrder", () => {
|
||||
it("repairs milestone ordering deterministically using createdAt and id tiebreakers", () => {
|
||||
const milestones = [
|
||||
createMilestone("RMS-C", "RM-1", 2, "2026-04-13T00:00:02.000Z"),
|
||||
createMilestone("RMS-B", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
createMilestone("RMS-A", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
const normalized = normalizeRoadmapMilestoneOrder(milestones);
|
||||
|
||||
expect(normalized.map((milestone) => milestone.id)).toEqual([
|
||||
"RMS-A",
|
||||
"RMS-B",
|
||||
"RMS-C",
|
||||
]);
|
||||
expect(normalized.map((milestone) => milestone.orderIndex)).toEqual([0, 1, 2]);
|
||||
expect(milestones.map((milestone) => milestone.orderIndex)).toEqual([2, 1, 1]);
|
||||
});
|
||||
|
||||
it("rejects mixed-roadmap milestone scopes", () => {
|
||||
const milestones = [
|
||||
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createMilestone("RMS-2", "RM-2", 1, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
expect(() => normalizeRoadmapMilestoneOrder(milestones)).toThrow(
|
||||
"Milestone RMS-2 does not belong to roadmap RM-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRoadmapMilestoneReorder", () => {
|
||||
it("reorders milestones and rewrites contiguous order indexes", () => {
|
||||
const milestones = [
|
||||
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createMilestone("RMS-2", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
createMilestone("RMS-3", "RM-1", 2, "2026-04-13T00:00:02.000Z"),
|
||||
];
|
||||
|
||||
const reordered = applyRoadmapMilestoneReorder(milestones, {
|
||||
roadmapId: "RM-1",
|
||||
orderedMilestoneIds: ["RMS-3", "RMS-1", "RMS-2"],
|
||||
});
|
||||
|
||||
expect(reordered.map((milestone) => milestone.id)).toEqual([
|
||||
"RMS-3",
|
||||
"RMS-1",
|
||||
"RMS-2",
|
||||
]);
|
||||
expect(reordered.map((milestone) => milestone.orderIndex)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("rejects duplicate milestone ids in reorder input", () => {
|
||||
const milestones = [
|
||||
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createMilestone("RMS-2", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
applyRoadmapMilestoneReorder(milestones, {
|
||||
roadmapId: "RM-1",
|
||||
orderedMilestoneIds: ["RMS-2", "RMS-2"],
|
||||
}),
|
||||
).toThrow("Duplicate milestone id in requested order: RMS-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeRoadmapFeatureOrder", () => {
|
||||
it("repairs feature ordering deterministically using createdAt and id tiebreakers", () => {
|
||||
const features = [
|
||||
createFeature("RF-C", "RMS-1", 3, "2026-04-13T00:00:03.000Z"),
|
||||
createFeature("RF-B", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
createFeature("RF-A", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
const normalized = normalizeRoadmapFeatureOrder(features);
|
||||
|
||||
expect(normalized.map((feature) => feature.id)).toEqual([
|
||||
"RF-A",
|
||||
"RF-B",
|
||||
"RF-C",
|
||||
]);
|
||||
expect(normalized.map((feature) => feature.orderIndex)).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRoadmapFeatureReorder", () => {
|
||||
it("reorders features within a milestone and rewrites contiguous order indexes", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
createFeature("RF-3", "RMS-1", 2, "2026-04-13T00:00:02.000Z"),
|
||||
];
|
||||
|
||||
const reordered = applyRoadmapFeatureReorder(features, {
|
||||
roadmapId: "RM-1",
|
||||
milestoneId: "RMS-1",
|
||||
orderedFeatureIds: ["RF-2", "RF-3", "RF-1"],
|
||||
});
|
||||
|
||||
expect(reordered.map((feature) => feature.id)).toEqual([
|
||||
"RF-2",
|
||||
"RF-3",
|
||||
"RF-1",
|
||||
]);
|
||||
expect(reordered.map((feature) => feature.orderIndex)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("rejects partial feature reorder payloads", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
applyRoadmapFeatureReorder(features, {
|
||||
roadmapId: "RM-1",
|
||||
milestoneId: "RMS-1",
|
||||
orderedFeatureIds: ["RF-2"],
|
||||
}),
|
||||
).toThrow("Expected 2 feature ids but received 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveRoadmapFeature", () => {
|
||||
it("moves a feature across milestones and normalizes both milestone orders", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-SOURCE", 1, "2026-04-13T00:00:01.000Z"),
|
||||
createFeature("RF-3", "RMS-TARGET", 0, "2026-04-13T00:00:02.000Z"),
|
||||
createFeature("RF-4", "RMS-TARGET", 1, "2026-04-13T00:00:03.000Z"),
|
||||
];
|
||||
|
||||
const result = moveRoadmapFeature(features, {
|
||||
roadmapId: "RM-1",
|
||||
featureId: "RF-2",
|
||||
fromMilestoneId: "RMS-SOURCE",
|
||||
toMilestoneId: "RMS-TARGET",
|
||||
targetOrderIndex: 1,
|
||||
});
|
||||
|
||||
expect(result.movedFeature).toMatchObject({
|
||||
id: "RF-2",
|
||||
milestoneId: "RMS-TARGET",
|
||||
orderIndex: 1,
|
||||
});
|
||||
expect(result.sourceMilestoneFeatures.map((feature) => feature.id)).toEqual([
|
||||
"RF-1",
|
||||
]);
|
||||
expect(result.sourceMilestoneFeatures.map((feature) => feature.orderIndex)).toEqual([0]);
|
||||
expect(result.targetMilestoneFeatures.map((feature) => feature.id)).toEqual([
|
||||
"RF-3",
|
||||
"RF-2",
|
||||
"RF-4",
|
||||
]);
|
||||
expect(result.targetMilestoneFeatures.map((feature) => feature.orderIndex)).toEqual([
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
]);
|
||||
expect(result.affectedFeatures).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("clamps same-milestone moves into range and returns a single normalized list", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
createFeature("RF-3", "RMS-1", 2, "2026-04-13T00:00:02.000Z"),
|
||||
];
|
||||
|
||||
const result = moveRoadmapFeature(features, {
|
||||
roadmapId: "RM-1",
|
||||
featureId: "RF-1",
|
||||
fromMilestoneId: "RMS-1",
|
||||
toMilestoneId: "RMS-1",
|
||||
targetOrderIndex: 99,
|
||||
});
|
||||
|
||||
expect(result.sourceMilestoneFeatures.map((feature) => feature.id)).toEqual([
|
||||
"RF-2",
|
||||
"RF-3",
|
||||
"RF-1",
|
||||
]);
|
||||
expect(result.targetMilestoneFeatures).toEqual(result.sourceMilestoneFeatures);
|
||||
expect(result.movedFeature.orderIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects features outside the affected milestone scope", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-OTHER", 0, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
moveRoadmapFeature(features, {
|
||||
roadmapId: "RM-1",
|
||||
featureId: "RF-1",
|
||||
fromMilestoneId: "RMS-SOURCE",
|
||||
toMilestoneId: "RMS-TARGET",
|
||||
targetOrderIndex: 0,
|
||||
}),
|
||||
).toThrow(
|
||||
"Feature RF-2 is outside the affected milestone scope (RMS-SOURCE → RMS-TARGET)",
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps negative targetOrderIndex to 0", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
const result = moveRoadmapFeature(features, {
|
||||
roadmapId: "RM-1",
|
||||
featureId: "RF-2",
|
||||
fromMilestoneId: "RMS-1",
|
||||
toMilestoneId: "RMS-1",
|
||||
targetOrderIndex: -5,
|
||||
});
|
||||
|
||||
// RF-2 should be moved to index 0, RF-1 to index 1
|
||||
expect(result.movedFeature.orderIndex).toBe(0);
|
||||
expect(result.sourceMilestoneFeatures.map((f) => f.id)).toEqual(["RF-2", "RF-1"]);
|
||||
});
|
||||
|
||||
it("clamps NaN targetOrderIndex to end", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
const result = moveRoadmapFeature(features, {
|
||||
roadmapId: "RM-1",
|
||||
featureId: "RF-1",
|
||||
fromMilestoneId: "RMS-1",
|
||||
toMilestoneId: "RMS-1",
|
||||
targetOrderIndex: NaN,
|
||||
});
|
||||
|
||||
// NaN is clamped to the end (length of the remaining list)
|
||||
expect(result.movedFeature.orderIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("clamps Infinity targetOrderIndex to end", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
const result = moveRoadmapFeature(features, {
|
||||
roadmapId: "RM-1",
|
||||
featureId: "RF-1",
|
||||
fromMilestoneId: "RMS-1",
|
||||
toMilestoneId: "RMS-1",
|
||||
targetOrderIndex: Infinity,
|
||||
});
|
||||
|
||||
// Infinity is clamped to the end
|
||||
expect(result.movedFeature.orderIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("produces strictly contiguous orderIndex values after move", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-SOURCE", 1, "2026-04-13T00:00:01.000Z"),
|
||||
createFeature("RF-3", "RMS-SOURCE", 2, "2026-04-13T00:00:02.000Z"),
|
||||
createFeature("RF-4", "RMS-TARGET", 0, "2026-04-13T00:00:03.000Z"),
|
||||
];
|
||||
|
||||
const result = moveRoadmapFeature(features, {
|
||||
roadmapId: "RM-1",
|
||||
featureId: "RF-2",
|
||||
fromMilestoneId: "RMS-SOURCE",
|
||||
toMilestoneId: "RMS-TARGET",
|
||||
targetOrderIndex: 0,
|
||||
});
|
||||
|
||||
// Verify contiguous orderIndex for source
|
||||
const sourceOrderIndices = result.sourceMilestoneFeatures.map((f) => f.orderIndex);
|
||||
expect(sourceOrderIndices).toEqual([0, 1]);
|
||||
expect(new Set(sourceOrderIndices).size).toBe(sourceOrderIndices.length);
|
||||
|
||||
// Verify contiguous orderIndex for target
|
||||
const targetOrderIndices = result.targetMilestoneFeatures.map((f) => f.orderIndex);
|
||||
expect(targetOrderIndices).toEqual([0, 1]);
|
||||
expect(new Set(targetOrderIndices).size).toBe(targetOrderIndices.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
163
plugins/fusion-plugin-roadmap/src/store/roadmap-handoff.ts
Normal file
163
plugins/fusion-plugin-roadmap/src/store/roadmap-handoff.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Pure mapping helpers for converting roadmap hierarchy data into mission/task planning handoffs.
|
||||
*
|
||||
* These helpers are read-only transformations that preserve source lineage and deterministic
|
||||
* ordering without coupling to MissionStore or task persistence.
|
||||
*
|
||||
* @module roadmap-handoff
|
||||
*/
|
||||
|
||||
import type {
|
||||
Roadmap,
|
||||
RoadmapMilestone,
|
||||
RoadmapFeature,
|
||||
RoadmapWithHierarchy,
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
RoadmapFeatureSourceRef,
|
||||
RoadmapMissionPlanningMilestoneHandoff,
|
||||
} from "./roadmap-types.js";
|
||||
|
||||
import {
|
||||
normalizeRoadmapMilestoneOrder,
|
||||
normalizeRoadmapFeatureOrder,
|
||||
} from "./roadmap-ordering.js";
|
||||
|
||||
/**
|
||||
* Build a source reference for a roadmap feature.
|
||||
*
|
||||
* Includes roadmap and milestone context for downstream planning prompts.
|
||||
*/
|
||||
function buildFeatureSourceRef(
|
||||
roadmap: Roadmap,
|
||||
milestone: RoadmapMilestone,
|
||||
feature: RoadmapFeature,
|
||||
): RoadmapFeatureSourceRef {
|
||||
return {
|
||||
roadmapId: roadmap.id,
|
||||
milestoneId: milestone.id,
|
||||
featureId: feature.id,
|
||||
roadmapTitle: roadmap.title,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneOrderIndex: milestone.orderIndex,
|
||||
featureOrderIndex: feature.orderIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single roadmap feature into a task planning handoff payload.
|
||||
*
|
||||
* The handoff preserves source lineage for traceability and deterministic ordering
|
||||
* for consistent downstream processing.
|
||||
*/
|
||||
export function mapFeatureToTaskHandoff(
|
||||
roadmap: Roadmap,
|
||||
milestone: RoadmapMilestone,
|
||||
feature: RoadmapFeature,
|
||||
): RoadmapFeatureTaskPlanningHandoff {
|
||||
return {
|
||||
source: buildFeatureSourceRef(roadmap, milestone, feature),
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a full roadmap hierarchy into a mission planning handoff payload.
|
||||
*
|
||||
* The handoff preserves deterministic ordering by normalizing milestone and feature
|
||||
* order indices before building the payload.
|
||||
*
|
||||
* @param roadmap - The roadmap to convert
|
||||
* @param milestones - Ordered milestones (will be re-normalized for deterministic output)
|
||||
* @param featuresByMilestoneId - Features grouped by milestone ID
|
||||
* @returns Mission planning handoff payload
|
||||
*/
|
||||
export function mapRoadmapToMissionHandoff(
|
||||
roadmap: Roadmap,
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
featuresByMilestoneId: ReadonlyMap<string, readonly RoadmapFeature[]>,
|
||||
): RoadmapMissionPlanningHandoff {
|
||||
// Normalize milestone ordering deterministically
|
||||
const normalizedMilestones = normalizeRoadmapMilestoneOrder(milestones);
|
||||
|
||||
const milestoneHandoffs: RoadmapMissionPlanningMilestoneHandoff[] = normalizedMilestones.map((milestone) => {
|
||||
// Get features for this milestone and normalize their order
|
||||
const rawFeatures = featuresByMilestoneId.get(milestone.id) ?? [];
|
||||
const normalizedFeatures = normalizeRoadmapFeatureOrder(rawFeatures);
|
||||
|
||||
return {
|
||||
sourceMilestoneId: milestone.id,
|
||||
title: milestone.title,
|
||||
description: milestone.description,
|
||||
orderIndex: milestone.orderIndex,
|
||||
features: normalizedFeatures.map((feature) => ({
|
||||
sourceFeatureId: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
orderIndex: feature.orderIndex,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
sourceRoadmapId: roadmap.id,
|
||||
title: roadmap.title,
|
||||
description: roadmap.description,
|
||||
milestones: milestoneHandoffs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a roadmap with full hierarchy into a mission planning handoff payload.
|
||||
*
|
||||
* Convenience overload that accepts the composite RoadmapWithHierarchy type.
|
||||
*/
|
||||
export function mapRoadmapWithHierarchyToMissionHandoff(
|
||||
roadmapWithHierarchy: RoadmapWithHierarchy,
|
||||
): RoadmapMissionPlanningHandoff {
|
||||
// Build features by milestone ID map
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
for (const milestone of roadmapWithHierarchy.milestones) {
|
||||
featuresByMilestoneId.set(milestone.id, milestone.features);
|
||||
}
|
||||
|
||||
return mapRoadmapToMissionHandoff(
|
||||
roadmapWithHierarchy,
|
||||
roadmapWithHierarchy.milestones,
|
||||
featuresByMilestoneId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all features from a roadmap into task planning handoff payloads.
|
||||
*
|
||||
* Flattens the roadmap hierarchy into individual feature handoffs, each preserving
|
||||
* source lineage and deterministic ordering.
|
||||
*
|
||||
* @param roadmap - The parent roadmap
|
||||
* @param milestones - Ordered milestones (will be re-normalized for deterministic output)
|
||||
* @param featuresByMilestoneId - Features grouped by milestone ID
|
||||
* @returns Array of feature task planning handoffs, ordered by milestone order then feature order
|
||||
*/
|
||||
export function mapAllFeaturesToTaskHandoffs(
|
||||
roadmap: Roadmap,
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
featuresByMilestoneId: ReadonlyMap<string, readonly RoadmapFeature[]>,
|
||||
): RoadmapFeatureTaskPlanningHandoff[] {
|
||||
// Normalize milestone ordering deterministically
|
||||
const normalizedMilestones = normalizeRoadmapMilestoneOrder(milestones);
|
||||
|
||||
const handoffs: RoadmapFeatureTaskPlanningHandoff[] = [];
|
||||
|
||||
for (const milestone of normalizedMilestones) {
|
||||
const rawFeatures = featuresByMilestoneId.get(milestone.id) ?? [];
|
||||
const normalizedFeatures = normalizeRoadmapFeatureOrder(rawFeatures);
|
||||
|
||||
for (const feature of normalizedFeatures) {
|
||||
handoffs.push(mapFeatureToTaskHandoff(roadmap, milestone, feature));
|
||||
}
|
||||
}
|
||||
|
||||
return handoffs;
|
||||
}
|
||||
311
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.ts
Normal file
311
plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import type {
|
||||
RoadmapFeature,
|
||||
RoadmapFeatureMoveInput,
|
||||
RoadmapFeatureMoveResult,
|
||||
RoadmapFeatureReorderInput,
|
||||
RoadmapMilestone,
|
||||
RoadmapMilestoneReorderInput,
|
||||
} from "./roadmap-types.js";
|
||||
|
||||
/**
|
||||
* Pure ordering helpers for the standalone roadmap model.
|
||||
*
|
||||
* Ordering invariants enforced by this module:
|
||||
* - helpers operate on scoped arrays (single roadmap for milestones, single
|
||||
* milestone for feature reorders, source+target milestones for feature moves)
|
||||
* - normalized `orderIndex` values are always contiguous + 0-based
|
||||
* - when stored order data is conflicting, helpers repair deterministically via
|
||||
* `orderIndex ASC`, `createdAt ASC`, `id ASC`
|
||||
* - explicit reorder helpers reject partial or duplicate ID lists instead of
|
||||
* guessing user intent
|
||||
*/
|
||||
|
||||
interface OrderedEntity {
|
||||
id: string;
|
||||
orderIndex: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function compareOrderedEntities<T extends OrderedEntity>(a: T, b: T): number {
|
||||
if (a.orderIndex !== b.orderIndex) {
|
||||
return a.orderIndex - b.orderIndex;
|
||||
}
|
||||
|
||||
if (a.createdAt !== b.createdAt) {
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
function clampInsertionIndex(targetIndex: number, length: number): number {
|
||||
if (!Number.isFinite(targetIndex)) {
|
||||
return length;
|
||||
}
|
||||
|
||||
const normalized = Math.trunc(targetIndex);
|
||||
if (normalized < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (normalized > length) {
|
||||
return length;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertScopedRoadmapMilestones(
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
roadmapId: string,
|
||||
): void {
|
||||
for (const milestone of milestones) {
|
||||
if (milestone.roadmapId !== roadmapId) {
|
||||
throw new Error(
|
||||
`Milestone ${milestone.id} does not belong to roadmap ${roadmapId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertScopedMilestoneFeatures(
|
||||
features: readonly RoadmapFeature[],
|
||||
milestoneId: string,
|
||||
): void {
|
||||
for (const feature of features) {
|
||||
if (feature.milestoneId !== milestoneId) {
|
||||
throw new Error(
|
||||
`Feature ${feature.id} does not belong to milestone ${milestoneId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertScopedMoveFeatures(
|
||||
features: readonly RoadmapFeature[],
|
||||
fromMilestoneId: string,
|
||||
toMilestoneId: string,
|
||||
): void {
|
||||
const validMilestoneIds = new Set([fromMilestoneId, toMilestoneId]);
|
||||
|
||||
for (const feature of features) {
|
||||
if (!validMilestoneIds.has(feature.milestoneId)) {
|
||||
throw new Error(
|
||||
`Feature ${feature.id} is outside the affected milestone scope (${fromMilestoneId} → ${toMilestoneId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactIdSet(
|
||||
entityLabel: string,
|
||||
actualIds: readonly string[],
|
||||
orderedIds: readonly string[],
|
||||
): void {
|
||||
const requestedIds = new Set<string>();
|
||||
|
||||
for (const id of orderedIds) {
|
||||
if (requestedIds.has(id)) {
|
||||
throw new Error(`Duplicate ${entityLabel} id in requested order: ${id}`);
|
||||
}
|
||||
requestedIds.add(id);
|
||||
}
|
||||
|
||||
if (actualIds.length !== orderedIds.length) {
|
||||
throw new Error(
|
||||
`Expected ${actualIds.length} ${entityLabel} ids but received ${orderedIds.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const actualIdSet = new Set(actualIds);
|
||||
|
||||
for (const id of orderedIds) {
|
||||
if (!actualIdSet.has(id)) {
|
||||
throw new Error(`${capitalize(entityLabel)} ${id} not found in scoped list`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of actualIds) {
|
||||
if (!requestedIds.has(id)) {
|
||||
throw new Error(`Missing ${entityLabel} id in requested order: ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function assignContiguousOrder<T extends OrderedEntity>(items: readonly T[]): T[] {
|
||||
return items.map((item, orderIndex) => {
|
||||
if (item.orderIndex === orderIndex) {
|
||||
return { ...item };
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
orderIndex,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs milestone ordering for a single roadmap scope.
|
||||
*
|
||||
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||
*/
|
||||
export function normalizeRoadmapMilestoneOrder(
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
): RoadmapMilestone[] {
|
||||
if (milestones.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
assertScopedRoadmapMilestones(milestones, milestones[0].roadmapId);
|
||||
|
||||
return assignContiguousOrder(
|
||||
[...milestones].sort(compareOrderedEntities),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an explicit milestone reorder for a single roadmap scope.
|
||||
*
|
||||
* The caller must provide the complete milestone ID set exactly once. Partial
|
||||
* or duplicate lists are rejected to keep reorders deterministic.
|
||||
*/
|
||||
export function applyRoadmapMilestoneReorder(
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
input: RoadmapMilestoneReorderInput,
|
||||
): RoadmapMilestone[] {
|
||||
assertScopedRoadmapMilestones(milestones, input.roadmapId);
|
||||
|
||||
const normalized = normalizeRoadmapMilestoneOrder(milestones);
|
||||
const ids = normalized.map((milestone) => milestone.id);
|
||||
assertExactIdSet("milestone", ids, input.orderedMilestoneIds);
|
||||
|
||||
const byId = new Map(normalized.map((milestone) => [milestone.id, milestone]));
|
||||
return assignContiguousOrder(
|
||||
input.orderedMilestoneIds.map((id) => byId.get(id)!),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs feature ordering for a single milestone scope.
|
||||
*
|
||||
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||
*/
|
||||
export function normalizeRoadmapFeatureOrder(
|
||||
features: readonly RoadmapFeature[],
|
||||
): RoadmapFeature[] {
|
||||
if (features.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
assertScopedMilestoneFeatures(features, features[0].milestoneId);
|
||||
|
||||
return assignContiguousOrder(
|
||||
[...features].sort(compareOrderedEntities),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an explicit feature reorder for a single milestone scope.
|
||||
*
|
||||
* The caller must provide the complete feature ID set exactly once. Partial or
|
||||
* duplicate lists are rejected to keep reorder behavior deterministic.
|
||||
*/
|
||||
export function applyRoadmapFeatureReorder(
|
||||
features: readonly RoadmapFeature[],
|
||||
input: RoadmapFeatureReorderInput,
|
||||
): RoadmapFeature[] {
|
||||
assertScopedMilestoneFeatures(features, input.milestoneId);
|
||||
|
||||
const normalized = normalizeRoadmapFeatureOrder(features);
|
||||
const ids = normalized.map((feature) => feature.id);
|
||||
assertExactIdSet("feature", ids, input.orderedFeatureIds);
|
||||
|
||||
const byId = new Map(normalized.map((feature) => [feature.id, feature]));
|
||||
return assignContiguousOrder(
|
||||
input.orderedFeatureIds.map((id) => byId.get(id)!),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a feature within the affected milestone scope and deterministically
|
||||
* normalizes both source and destination order.
|
||||
*
|
||||
* For cross-milestone moves, pass the combined feature list from the source and
|
||||
* target milestones. For within-milestone moves, pass the current milestone's
|
||||
* feature list. `targetOrderIndex` is clamped into the destination range.
|
||||
*/
|
||||
export function moveRoadmapFeature(
|
||||
features: readonly RoadmapFeature[],
|
||||
input: RoadmapFeatureMoveInput,
|
||||
): RoadmapFeatureMoveResult {
|
||||
assertScopedMoveFeatures(features, input.fromMilestoneId, input.toMilestoneId);
|
||||
|
||||
const existingFeature = features.find((feature) => feature.id === input.featureId);
|
||||
if (!existingFeature) {
|
||||
throw new Error(`Feature ${input.featureId} not found in affected milestone scope`);
|
||||
}
|
||||
|
||||
if (existingFeature.milestoneId !== input.fromMilestoneId) {
|
||||
throw new Error(
|
||||
`Feature ${input.featureId} does not belong to milestone ${input.fromMilestoneId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceFeatures = normalizeRoadmapFeatureOrder(
|
||||
features.filter((feature) => feature.milestoneId === input.fromMilestoneId),
|
||||
);
|
||||
const sourceWithoutFeature = sourceFeatures.filter(
|
||||
(feature) => feature.id !== input.featureId,
|
||||
);
|
||||
|
||||
if (input.fromMilestoneId === input.toMilestoneId) {
|
||||
const insertionIndex = clampInsertionIndex(
|
||||
input.targetOrderIndex,
|
||||
sourceWithoutFeature.length,
|
||||
);
|
||||
const reordered = [...sourceWithoutFeature];
|
||||
reordered.splice(insertionIndex, 0, {
|
||||
...existingFeature,
|
||||
milestoneId: input.toMilestoneId,
|
||||
orderIndex: insertionIndex,
|
||||
});
|
||||
|
||||
const normalized = assignContiguousOrder(reordered);
|
||||
const movedFeature = normalized.find((feature) => feature.id === input.featureId)!;
|
||||
|
||||
return {
|
||||
movedFeature,
|
||||
affectedFeatures: normalized,
|
||||
sourceMilestoneFeatures: normalized,
|
||||
targetMilestoneFeatures: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
const targetFeatures = normalizeRoadmapFeatureOrder(
|
||||
features.filter((feature) => feature.milestoneId === input.toMilestoneId),
|
||||
);
|
||||
const insertionIndex = clampInsertionIndex(
|
||||
input.targetOrderIndex,
|
||||
targetFeatures.length,
|
||||
);
|
||||
const targetWithInsertedFeature = [...targetFeatures];
|
||||
targetWithInsertedFeature.splice(insertionIndex, 0, {
|
||||
...existingFeature,
|
||||
milestoneId: input.toMilestoneId,
|
||||
orderIndex: insertionIndex,
|
||||
});
|
||||
|
||||
const normalizedSource = assignContiguousOrder(sourceWithoutFeature);
|
||||
const normalizedTarget = assignContiguousOrder(targetWithInsertedFeature);
|
||||
const movedFeature = normalizedTarget.find((feature) => feature.id === input.featureId)!;
|
||||
|
||||
return {
|
||||
movedFeature,
|
||||
affectedFeatures: [...normalizedSource, ...normalizedTarget],
|
||||
sourceMilestoneFeatures: normalizedSource,
|
||||
targetMilestoneFeatures: normalizedTarget,
|
||||
};
|
||||
}
|
||||
960
plugins/fusion-plugin-roadmap/src/store/roadmap-store.ts
Normal file
960
plugins/fusion-plugin-roadmap/src/store/roadmap-store.ts
Normal file
@@ -0,0 +1,960 @@
|
||||
/**
|
||||
* 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,
|
||||
RoadmapFeatureSourceRef,
|
||||
} from "../roadmap-types.js";
|
||||
import {
|
||||
applyRoadmapMilestoneReorder,
|
||||
applyRoadmapFeatureReorder,
|
||||
moveRoadmapFeature,
|
||||
} from "./roadmap-ordering.js";
|
||||
|
||||
// ── Event Types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface RoadmapStoreEvents {
|
||||
/** Emitted when a roadmap is created */
|
||||
"roadmap:created": [Roadmap];
|
||||
/** Emitted when a roadmap is updated */
|
||||
"roadmap:updated": [Roadmap];
|
||||
/** Emitted when a roadmap is deleted */
|
||||
"roadmap:deleted": [string];
|
||||
/** Emitted when a milestone is created */
|
||||
"milestone:created": [RoadmapMilestone];
|
||||
/** Emitted when a milestone is updated */
|
||||
"milestone:updated": [RoadmapMilestone];
|
||||
/** Emitted when a milestone is deleted */
|
||||
"milestone:deleted": [string];
|
||||
/** Emitted when a milestone is reordered */
|
||||
"milestone:reordered": [{ roadmapId: string; milestones: RoadmapMilestone[] }];
|
||||
/** Emitted when a feature is created */
|
||||
"feature:created": [RoadmapFeature];
|
||||
/** Emitted when a feature is updated */
|
||||
"feature:updated": [RoadmapFeature];
|
||||
/** Emitted when a feature is deleted */
|
||||
"feature:deleted": [RoadmapFeature];
|
||||
/** Emitted when features are reordered within a milestone */
|
||||
"feature:reordered": [{ milestoneId: string; features: RoadmapFeature[] }];
|
||||
/** Emitted when a feature is moved (including cross-milestone moves) */
|
||||
"feature:moved": [{ feature: RoadmapFeature; fromMilestoneId: string; toMilestoneId: string }];
|
||||
}
|
||||
|
||||
// ── Row Interfaces ──────────────────────────────────────────────────
|
||||
|
||||
/** Database row shape for roadmaps. */
|
||||
interface RoadmapRow {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Database row shape for roadmap_milestones. */
|
||||
interface RoadmapMilestoneRow {
|
||||
id: string;
|
||||
roadmapId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
orderIndex: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Database row shape for roadmap_features. */
|
||||
interface RoadmapFeatureRow {
|
||||
id: string;
|
||||
milestoneId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
orderIndex: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── RoadmapStore Class ──────────────────────────────────────────────
|
||||
|
||||
export class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
|
||||
/**
|
||||
* Creates a new RoadmapStore instance.
|
||||
*
|
||||
* @param db - Shared Database instance (same instance used by TaskStore)
|
||||
*/
|
||||
constructor(private db: Database) {
|
||||
super();
|
||||
this.setMaxListeners(50);
|
||||
}
|
||||
|
||||
// ── ID Generators ───────────────────────────────────────────────────
|
||||
|
||||
private generateRoadmapId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `RM-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
|
||||
private generateMilestoneId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `RMS-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
|
||||
private generateFeatureId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `RF-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
|
||||
// ── Row-to-Object Converters ───────────────────────────────────────
|
||||
|
||||
private rowToRoadmap(row: RoadmapRow): Roadmap {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description || undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private rowToMilestone(row: RoadmapMilestoneRow): RoadmapMilestone {
|
||||
return {
|
||||
id: row.id,
|
||||
roadmapId: row.roadmapId,
|
||||
title: row.title,
|
||||
description: row.description || undefined,
|
||||
orderIndex: row.orderIndex,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private rowToFeature(row: RoadmapFeatureRow): RoadmapFeature {
|
||||
return {
|
||||
id: row.id,
|
||||
milestoneId: row.milestoneId,
|
||||
title: row.title,
|
||||
description: row.description || undefined,
|
||||
orderIndex: row.orderIndex,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Roadmap CRUD ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new roadmap.
|
||||
*
|
||||
* @param input - Roadmap creation input
|
||||
* @returns The created roadmap
|
||||
*/
|
||||
createRoadmap(input: RoadmapCreateInput): Roadmap {
|
||||
const now = new Date().toISOString();
|
||||
const id = this.generateRoadmapId();
|
||||
|
||||
const roadmap: Roadmap = {
|
||||
id,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO roadmaps (id, title, description, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
roadmap.id,
|
||||
roadmap.title,
|
||||
roadmap.description ?? null,
|
||||
roadmap.createdAt,
|
||||
roadmap.updatedAt,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("roadmap:created", roadmap);
|
||||
return roadmap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a roadmap by ID.
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @returns The roadmap, or undefined if not found
|
||||
*/
|
||||
getRoadmap(id: string): Roadmap | undefined {
|
||||
const row = this.db.prepare("SELECT * FROM roadmaps WHERE id = ?").get(id) as unknown as RoadmapRow | undefined;
|
||||
if (!row) return undefined;
|
||||
return this.rowToRoadmap(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all roadmaps, ordered by creation date (newest first).
|
||||
*
|
||||
* @returns Array of roadmaps
|
||||
*/
|
||||
listRoadmaps(): Roadmap[] {
|
||||
const rows = this.db.prepare(
|
||||
"SELECT * FROM roadmaps ORDER BY createdAt DESC"
|
||||
).all();
|
||||
return (rows as unknown as RoadmapRow[]).map((row) => this.rowToRoadmap(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a roadmap.
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @param updates - Partial roadmap updates
|
||||
* @returns The updated roadmap
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
updateRoadmap(id: string, updates: RoadmapUpdateInput): Roadmap {
|
||||
const roadmap = this.getRoadmap(id);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${id} not found`);
|
||||
}
|
||||
|
||||
const updated: Roadmap = {
|
||||
...roadmap,
|
||||
...updates,
|
||||
id, // Prevent changing ID
|
||||
createdAt: roadmap.createdAt, // Prevent changing creation time
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
UPDATE roadmaps SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
updated.title,
|
||||
updated.description ?? null,
|
||||
updated.updatedAt,
|
||||
updated.id,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("roadmap:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a roadmap and all its milestones/features (cascading).
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
deleteRoadmap(id: string): void {
|
||||
const roadmap = this.getRoadmap(id);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${id} not found`);
|
||||
}
|
||||
|
||||
// SQLite FK cascade will handle milestones and features
|
||||
this.db.prepare("DELETE FROM roadmaps WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
this.emit("roadmap:deleted", id);
|
||||
}
|
||||
|
||||
// ── Milestone CRUD ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a milestone to a roadmap.
|
||||
* Automatically computes the orderIndex (max + 1).
|
||||
*
|
||||
* @param roadmapId - Parent roadmap ID
|
||||
* @param input - Milestone creation input
|
||||
* @returns The created milestone
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
createMilestone(roadmapId: string, input: RoadmapMilestoneCreateInput): RoadmapMilestone {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const id = this.generateMilestoneId();
|
||||
|
||||
// Compute next orderIndex
|
||||
const existingMilestones = this.listMilestones(roadmapId);
|
||||
const orderIndex = existingMilestones.length > 0
|
||||
? Math.max(...existingMilestones.map((m) => m.orderIndex)) + 1
|
||||
: 0;
|
||||
|
||||
const milestone: RoadmapMilestone = {
|
||||
id,
|
||||
roadmapId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
orderIndex,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO roadmap_milestones (id, roadmapId, title, description, orderIndex, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
milestone.id,
|
||||
milestone.roadmapId,
|
||||
milestone.title,
|
||||
milestone.description ?? null,
|
||||
milestone.orderIndex,
|
||||
milestone.createdAt,
|
||||
milestone.updatedAt,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("milestone:created", milestone);
|
||||
return milestone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a milestone by ID.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @returns The milestone, or undefined if not found
|
||||
*/
|
||||
getMilestone(id: string): RoadmapMilestone | undefined {
|
||||
const row = this.db.prepare("SELECT * FROM roadmap_milestones WHERE id = ?").get(id) as unknown as RoadmapMilestoneRow | undefined;
|
||||
if (!row) return undefined;
|
||||
return this.rowToMilestone(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* List milestones for a roadmap, ordered deterministically.
|
||||
*
|
||||
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||
* to ensure consistent results when stored order data is incomplete or conflicting.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns Array of milestones in deterministic order
|
||||
*/
|
||||
listMilestones(roadmapId: string): RoadmapMilestone[] {
|
||||
const rows = this.db.prepare(
|
||||
"SELECT * FROM roadmap_milestones WHERE roadmapId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC"
|
||||
).all(roadmapId);
|
||||
return (rows as unknown as RoadmapMilestoneRow[]).map((row) => this.rowToMilestone(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a milestone.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @param updates - Partial milestone updates
|
||||
* @returns The updated milestone
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
updateMilestone(id: string, updates: RoadmapMilestoneUpdateInput): RoadmapMilestone {
|
||||
const milestone = this.getMilestone(id);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${id} not found`);
|
||||
}
|
||||
|
||||
const updated: RoadmapMilestone = {
|
||||
...milestone,
|
||||
...updates,
|
||||
id, // Prevent changing ID
|
||||
roadmapId: milestone.roadmapId, // Prevent moving to different roadmap
|
||||
createdAt: milestone.createdAt, // Prevent changing creation time
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_milestones SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
updated.title,
|
||||
updated.description ?? null,
|
||||
updated.updatedAt,
|
||||
updated.id,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("milestone:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a milestone and all its features (cascading).
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
deleteMilestone(id: string): void {
|
||||
const milestone = this.getMilestone(id);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${id} not found`);
|
||||
}
|
||||
|
||||
// SQLite FK cascade will handle features
|
||||
this.db.prepare("DELETE FROM roadmap_milestones WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
this.emit("milestone:deleted", id);
|
||||
}
|
||||
|
||||
// ── Feature CRUD ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a feature to a milestone.
|
||||
* Automatically computes the orderIndex (max + 1).
|
||||
*
|
||||
* @param milestoneId - Parent milestone ID
|
||||
* @param input - Feature creation input
|
||||
* @returns The created feature
|
||||
* @throws Error if milestone not found
|
||||
*/
|
||||
createFeature(milestoneId: string, input: RoadmapFeatureCreateInput): RoadmapFeature {
|
||||
const milestone = this.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${milestoneId} not found`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const id = this.generateFeatureId();
|
||||
|
||||
// Compute next orderIndex
|
||||
const existingFeatures = this.listFeatures(milestoneId);
|
||||
const orderIndex = existingFeatures.length > 0
|
||||
? Math.max(...existingFeatures.map((f) => f.orderIndex)) + 1
|
||||
: 0;
|
||||
|
||||
const feature: RoadmapFeature = {
|
||||
id,
|
||||
milestoneId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
orderIndex,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO roadmap_features (id, milestoneId, title, description, orderIndex, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
feature.id,
|
||||
feature.milestoneId,
|
||||
feature.title,
|
||||
feature.description ?? null,
|
||||
feature.orderIndex,
|
||||
feature.createdAt,
|
||||
feature.updatedAt,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("feature:created", feature);
|
||||
return feature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a feature by ID.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @returns The feature, or undefined if not found
|
||||
*/
|
||||
getFeature(id: string): RoadmapFeature | undefined {
|
||||
const row = this.db.prepare("SELECT * FROM roadmap_features WHERE id = ?").get(id) as unknown as RoadmapFeatureRow | undefined;
|
||||
if (!row) return undefined;
|
||||
return this.rowToFeature(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* List features for a milestone, ordered deterministically.
|
||||
*
|
||||
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
|
||||
* to ensure consistent results when stored order data is incomplete or conflicting.
|
||||
*
|
||||
* @param milestoneId - Milestone ID
|
||||
* @returns Array of features in deterministic order
|
||||
*/
|
||||
listFeatures(milestoneId: string): RoadmapFeature[] {
|
||||
const rows = this.db.prepare(
|
||||
"SELECT * FROM roadmap_features WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC"
|
||||
).all(milestoneId);
|
||||
return (rows as unknown as RoadmapFeatureRow[]).map((row) => this.rowToFeature(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a feature.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @param updates - Partial feature updates
|
||||
* @returns The updated feature
|
||||
* @throws Error if feature not found
|
||||
*/
|
||||
updateFeature(id: string, updates: RoadmapFeatureUpdateInput): RoadmapFeature {
|
||||
const feature = this.getFeature(id);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${id} not found`);
|
||||
}
|
||||
|
||||
const updated: RoadmapFeature = {
|
||||
...feature,
|
||||
...updates,
|
||||
id, // Prevent changing ID
|
||||
milestoneId: feature.milestoneId, // Prevent moving via update (use moveFeature instead)
|
||||
createdAt: feature.createdAt, // Prevent changing creation time
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_features SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
updated.title,
|
||||
updated.description ?? null,
|
||||
updated.updatedAt,
|
||||
updated.id,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("feature:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a feature.
|
||||
*
|
||||
* @param id - Feature ID
|
||||
* @throws Error if feature not found
|
||||
*/
|
||||
deleteFeature(id: string): void {
|
||||
const feature = this.getFeature(id);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${id} not found`);
|
||||
}
|
||||
|
||||
this.db.prepare("DELETE FROM roadmap_features WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
this.emit("feature:deleted", feature);
|
||||
}
|
||||
|
||||
// ── Reorder Operations ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reorder milestones within a roadmap.
|
||||
*
|
||||
* Applies an explicit reorder input and persists the full normalized order.
|
||||
* The input must contain all milestone IDs exactly once.
|
||||
*
|
||||
* @param input - Reorder input with complete milestone ID list
|
||||
* @returns The reordered milestones in their new order
|
||||
* @throws Error if milestone set is incomplete, duplicate, or not found
|
||||
*/
|
||||
reorderMilestones(input: RoadmapMilestoneReorderInput): RoadmapMilestone[] {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(input.roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${input.roadmapId} not found`);
|
||||
}
|
||||
|
||||
// Load current milestones with deterministic ordering
|
||||
const milestones = this.listMilestones(input.roadmapId);
|
||||
|
||||
// Apply the reorder using the pure ordering helper
|
||||
const reordered = applyRoadmapMilestoneReorder(milestones, input);
|
||||
|
||||
// Persist in a transaction
|
||||
this.db.transaction(() => {
|
||||
for (const milestone of reordered) {
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_milestones SET orderIndex = ?, updatedAt = ? WHERE id = ?
|
||||
`).run(milestone.orderIndex, new Date().toISOString(), milestone.id);
|
||||
}
|
||||
});
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("milestone:reordered", { roadmapId: input.roadmapId, milestones: reordered });
|
||||
|
||||
return reordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder features within a milestone.
|
||||
*
|
||||
* Applies an explicit reorder input and persists the full normalized order.
|
||||
* The input must contain all feature IDs for the milestone exactly once.
|
||||
*
|
||||
* @param input - Reorder input with complete feature ID list
|
||||
* @returns The reordered features in their new order
|
||||
* @throws Error if feature set is incomplete, duplicate, or not found
|
||||
*/
|
||||
reorderFeatures(input: RoadmapFeatureReorderInput): RoadmapFeature[] {
|
||||
// Validate milestone exists and belongs to the roadmap
|
||||
const milestone = this.getMilestone(input.milestoneId);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${input.milestoneId} not found`);
|
||||
}
|
||||
if (milestone.roadmapId !== input.roadmapId) {
|
||||
throw new Error(`Milestone ${input.milestoneId} does not belong to roadmap ${input.roadmapId}`);
|
||||
}
|
||||
|
||||
// Load current features with deterministic ordering
|
||||
const features = this.listFeatures(input.milestoneId);
|
||||
|
||||
// Apply the reorder using the pure ordering helper
|
||||
const reordered = applyRoadmapFeatureReorder(features, input);
|
||||
|
||||
// Persist in a transaction
|
||||
this.db.transaction(() => {
|
||||
for (const feature of reordered) {
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_features SET orderIndex = ?, updatedAt = ? WHERE id = ?
|
||||
`).run(feature.orderIndex, new Date().toISOString(), feature.id);
|
||||
}
|
||||
});
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("feature:reordered", { milestoneId: input.milestoneId, features: reordered });
|
||||
|
||||
return reordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a feature, including cross-milestone moves.
|
||||
*
|
||||
* Atomically renumbers both the source and destination milestone scopes.
|
||||
*
|
||||
* @param input - Move input with source/destination milestone info
|
||||
* @returns The moved feature and both affected milestone feature lists
|
||||
* @throws Error if feature or milestone not found, or scope validation fails
|
||||
*/
|
||||
moveFeature(input: RoadmapFeatureMoveInput): {
|
||||
movedFeature: RoadmapFeature;
|
||||
sourceMilestoneFeatures: RoadmapFeature[];
|
||||
targetMilestoneFeatures: RoadmapFeature[];
|
||||
} {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(input.roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${input.roadmapId} not found`);
|
||||
}
|
||||
|
||||
// Validate both milestones exist and belong to the roadmap
|
||||
const fromMilestone = this.getMilestone(input.fromMilestoneId);
|
||||
const toMilestone = this.getMilestone(input.toMilestoneId);
|
||||
|
||||
if (!fromMilestone) {
|
||||
throw new Error(`Source milestone ${input.fromMilestoneId} not found`);
|
||||
}
|
||||
if (!toMilestone) {
|
||||
throw new Error(`Destination milestone ${input.toMilestoneId} not found`);
|
||||
}
|
||||
if (fromMilestone.roadmapId !== input.roadmapId) {
|
||||
throw new Error(`Source milestone ${input.fromMilestoneId} does not belong to roadmap ${input.roadmapId}`);
|
||||
}
|
||||
if (toMilestone.roadmapId !== input.roadmapId) {
|
||||
throw new Error(`Destination milestone ${input.toMilestoneId} does not belong to roadmap ${input.roadmapId}`);
|
||||
}
|
||||
|
||||
// Load features from both milestones with deterministic ordering
|
||||
const sourceFeatures = this.listFeatures(input.fromMilestoneId);
|
||||
const targetFeatures = this.listFeatures(input.toMilestoneId);
|
||||
|
||||
// For same-milestone moves, pass only one list to avoid duplication
|
||||
// For cross-milestone moves, pass the combined list
|
||||
const allFeatures = input.fromMilestoneId === input.toMilestoneId
|
||||
? sourceFeatures
|
||||
: [...sourceFeatures, ...targetFeatures];
|
||||
|
||||
// Apply the move using the pure ordering helper
|
||||
const result = moveRoadmapFeature(allFeatures, input);
|
||||
|
||||
// Persist in a transaction
|
||||
this.db.transaction(() => {
|
||||
// Update all affected features
|
||||
for (const feature of result.affectedFeatures) {
|
||||
this.db.prepare(`
|
||||
UPDATE roadmap_features SET milestoneId = ?, orderIndex = ?, updatedAt = ? WHERE id = ?
|
||||
`).run(feature.milestoneId, feature.orderIndex, new Date().toISOString(), feature.id);
|
||||
}
|
||||
});
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("feature:moved", {
|
||||
feature: result.movedFeature,
|
||||
fromMilestoneId: input.fromMilestoneId,
|
||||
toMilestoneId: input.toMilestoneId,
|
||||
});
|
||||
|
||||
return {
|
||||
movedFeature: result.movedFeature,
|
||||
sourceMilestoneFeatures: result.sourceMilestoneFeatures,
|
||||
targetMilestoneFeatures: result.targetMilestoneFeatures,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Hierarchy Operations ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a milestone with all of its features in deterministic order.
|
||||
*
|
||||
* @param id - Milestone ID
|
||||
* @returns The milestone with features, or undefined if not found
|
||||
*/
|
||||
getMilestoneWithFeatures(id: string): RoadmapMilestoneWithFeatures | undefined {
|
||||
const milestone = this.getMilestone(id);
|
||||
if (!milestone) return undefined;
|
||||
|
||||
return {
|
||||
...milestone,
|
||||
features: this.listFeatures(id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a roadmap with its full hierarchy (milestones → features).
|
||||
*
|
||||
* @param id - Roadmap ID
|
||||
* @returns The roadmap with hierarchy, or undefined if not found
|
||||
*/
|
||||
getRoadmapWithHierarchy(id: string): RoadmapWithHierarchy | undefined {
|
||||
const roadmap = this.getRoadmap(id);
|
||||
if (!roadmap) return undefined;
|
||||
|
||||
return {
|
||||
...roadmap,
|
||||
milestones: this.listMilestones(id).map((milestone) => ({
|
||||
...milestone,
|
||||
features: this.listFeatures(milestone.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Export / Handoff Operations ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a flat export bundle for a roadmap.
|
||||
*
|
||||
* Returns all roadmap data in a flat structure suitable for persistence,
|
||||
* APIs, import/export, and sync jobs. Entities are separated so downstream
|
||||
* persistence layers can upsert by table/collection.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The export bundle with ordered entities
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getRoadmapExport(roadmapId: string): RoadmapExportBundle {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
const allFeatures: RoadmapFeature[] = [];
|
||||
|
||||
for (const milestone of milestones) {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
allFeatures.push(...features);
|
||||
}
|
||||
|
||||
return {
|
||||
roadmap,
|
||||
milestones,
|
||||
features: allFeatures,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mission planning handoff payload for a roadmap.
|
||||
*
|
||||
* Converts the roadmap into a mission planning structure while preserving
|
||||
* source IDs and deterministic order. Does not couple to MissionStore internals.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The mission planning handoff payload
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getRoadmapMissionHandoff(roadmapId: string): RoadmapMissionPlanningHandoff {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
|
||||
return {
|
||||
sourceRoadmapId: roadmap.id,
|
||||
title: roadmap.title,
|
||||
description: roadmap.description,
|
||||
milestones: milestones.map((milestone) => {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
|
||||
return {
|
||||
sourceMilestoneId: milestone.id,
|
||||
title: milestone.title,
|
||||
description: milestone.description,
|
||||
orderIndex: milestone.orderIndex,
|
||||
features: features.map((feature) => ({
|
||||
sourceFeatureId: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
orderIndex: feature.orderIndex,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a task planning handoff payload for a single roadmap feature.
|
||||
*
|
||||
* Returns a self-contained handoff payload for converting a roadmap feature
|
||||
* into task planning flows without coupling to MissionStore internals.
|
||||
*
|
||||
* @param roadmapId - Parent roadmap ID (for validation)
|
||||
* @param milestoneId - Parent milestone ID (for validation)
|
||||
* @param featureId - Feature ID to generate handoff for
|
||||
* @returns The task planning handoff payload
|
||||
* @throws Error if any entity is not found or if ownership validation fails
|
||||
*/
|
||||
getRoadmapFeatureHandoff(
|
||||
roadmapId: string,
|
||||
milestoneId: string,
|
||||
featureId: string,
|
||||
): RoadmapFeatureTaskPlanningHandoff {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
// Validate milestone exists and belongs to roadmap
|
||||
const milestone = this.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${milestoneId} not found`);
|
||||
}
|
||||
if (milestone.roadmapId !== roadmapId) {
|
||||
throw new Error(`Milestone ${milestoneId} does not belong to roadmap ${roadmapId}`);
|
||||
}
|
||||
|
||||
// Validate feature exists and belongs to milestone
|
||||
const feature = this.getFeature(featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
if (feature.milestoneId !== milestoneId) {
|
||||
throw new Error(`Feature ${featureId} does not belong to milestone ${milestoneId}`);
|
||||
}
|
||||
|
||||
// Build the source reference with ordering context
|
||||
const source: RoadmapFeatureSourceRef = {
|
||||
roadmapId: roadmap.id,
|
||||
milestoneId: milestone.id,
|
||||
featureId: feature.id,
|
||||
roadmapTitle: roadmap.title,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneOrderIndex: milestone.orderIndex,
|
||||
featureOrderIndex: feature.orderIndex,
|
||||
};
|
||||
|
||||
return {
|
||||
source,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mission planning handoff payload for a roadmap.
|
||||
*
|
||||
* Alias for getRoadmapMissionHandoff() for API consistency.
|
||||
* Converts the roadmap into a mission planning structure while preserving
|
||||
* source IDs and deterministic order.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The mission planning handoff payload
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getMissionPlanningHandoff(roadmapId: string): RoadmapMissionPlanningHandoff {
|
||||
return this.getRoadmapMissionHandoff(roadmapId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all task planning handoff payloads for a roadmap.
|
||||
*
|
||||
* Returns a flat list of all feature handoffs in deterministic order
|
||||
* (milestone order index, then feature order index).
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns Array of task planning handoff payloads for all features
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
listFeatureTaskPlanningHandoffs(roadmapId: string): RoadmapFeatureTaskPlanningHandoff[] {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
const handoffs: RoadmapFeatureTaskPlanningHandoff[] = [];
|
||||
|
||||
for (const milestone of milestones) {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
|
||||
for (const feature of features) {
|
||||
const source: RoadmapFeatureSourceRef = {
|
||||
roadmapId: roadmap.id,
|
||||
milestoneId: milestone.id,
|
||||
featureId: feature.id,
|
||||
roadmapTitle: roadmap.title,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneOrderIndex: milestone.orderIndex,
|
||||
featureOrderIndex: feature.orderIndex,
|
||||
};
|
||||
|
||||
handoffs.push({
|
||||
source,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return handoffs;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user