feat(FN-3637): establish canonical roadmap plugin ID and compatibility rout
Merged commits stabilize the Fusion roadmap plugin's identity and routing surface, establishing a canonical plugin ID and compatibility routes so the roadmap plugin integrates cleanly with the dashboard's plugin system. Added new roadmap-routes and roadmap-suggestions modules in the dashboard packag Fusion-Task-Id: FN-3637
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Align the bundled roadmap plugin to the canonical `fusion-plugin-roadmap` runtime id, expose roadmap APIs under `/api/plugins/fusion-plugin-roadmap/...`, and restore `/api/roadmaps` compatibility routing through plugin-owned handlers during migration.
|
||||
@@ -573,8 +573,8 @@ Route handlers may return either plain JSON values or a `PluginRouteResponse` en
|
||||
Routes are mounted at `/api/plugins/{pluginId}/{path}`.
|
||||
Route handlers receive the same loader-built `PluginContext` used by hooks/tools, including real `taskStore`, plugin `settings`, `logger`, `emitEvent`, and engine-injected `createAiSession` (when available):
|
||||
|
||||
- Example roadmap plugin route: `path: "/roadmaps"` in plugin `roadmap-planner` resolves to `/api/plugins/roadmap-planner/roadmaps`
|
||||
- Roadmap suggestion endpoints follow the same namespace (for example `/api/plugins/roadmap-planner/roadmaps/:roadmapId/suggestions/milestones`)
|
||||
- Example roadmap plugin route: `path: "/roadmaps"` in plugin `fusion-plugin-roadmap` resolves to `/api/plugins/fusion-plugin-roadmap/roadmaps`
|
||||
- Roadmap suggestion endpoints follow the same namespace (for example `/api/plugins/fusion-plugin-roadmap/roadmaps/:roadmapId/suggestions/milestones`)
|
||||
- Do not document or depend on legacy host-owned `/api/roadmaps` routes unless your current source still ships them
|
||||
|
||||
- Plugin ID: `fusion-plugin-notification`
|
||||
@@ -1264,7 +1264,7 @@ Polls CI status for branches and provides custom API endpoints.
|
||||
Standalone roadmap planning plugin extracted from dashboard host code.
|
||||
|
||||
- Demonstrates: `hooks.onSchemaInit` for plugin-owned schema DDL (`ensureRoadmapSchema`)
|
||||
- Demonstrates: plugin-scoped route namespace under `/api/plugins/roadmap-planner/*`
|
||||
- Demonstrates: plugin-scoped route namespace under `/api/plugins/fusion-plugin-roadmap/*`
|
||||
- Demonstrates: top-level navigation registration through `dashboardViews` (`viewId: "roadmaps"`) and host static view registration
|
||||
- Demonstrates: AI suggestion flows that consume `ctx.createAiSession` through plugin route handlers
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ Concrete references:
|
||||
- Core schema tables include: `tasks`, `config`, `workflow_steps`, `activityLog`, `archivedTasks`, `automations`, `agents`, `agentHeartbeats`, approval tables (`approval_requests`, `approval_request_audit_events`), `task_documents`, `task_document_revisions`, mission hierarchy tables (`missions`, `milestones`, `slices`, `mission_features`, `mission_events`), plugin/routine tables (`plugins`, `routines`), roadmap tables (`roadmaps`, `roadmap_milestones`, `roadmap_features`), insight tables (`project_insights`, `project_insight_runs`), research tables (`research_runs`, `research_exports`, `research_run_events`), eval tables (`eval_runs`, `eval_task_results`, `eval_run_events`), todo tables (`todo_lists`, `todo_items`), `__meta`
|
||||
- Migration-created tables include: `ai_sessions`, `messages`, `agentRatings`, `chat_sessions`, `chat_messages`, `runAuditEvents`, `mission_contract_assertions`, `mission_feature_assertions`, `mission_validator_runs`, `mission_validator_failures`, `mission_fix_feature_lineage`
|
||||
- `ai_sessions.status` lifecycle includes `draft` (pre-start planning session), then `generating`, `awaiting_input`, terminal `complete` / `error`
|
||||
- **Roadmap feature ownership**: roadmap contracts, ordering/handoff helpers, persistence, routes, and dashboard UI live in `plugins/fusion-plugin-roadmap` (package `@fusion-plugin-examples/roadmap`) rather than dashboard/core ownership.
|
||||
- **Roadmap feature ownership**: roadmap contracts, ordering/handoff helpers, persistence, routes, and dashboard UI live in `plugins/fusion-plugin-roadmap` (package `@fusion-plugin-examples/roadmap`, plugin id `fusion-plugin-roadmap`) rather than dashboard/core ownership.
|
||||
- **CentralCore**: `packages/core/src/central-core.ts`
|
||||
- Global project registry, health, central activity feed, global concurrency
|
||||
- Backed by `packages/core/src/central-db.ts` (`~/.fusion/fusion-central.db`)
|
||||
@@ -425,12 +425,12 @@ Key roadmap invariants:
|
||||
- cross-milestone feature moves must renumber both the source and destination milestone deterministically
|
||||
|
||||
**Roadmap frontend API contract (plugin namespace):**
|
||||
- Canonical frontend namespace: `/api/plugins/roadmap-planner/roadmaps`
|
||||
- Canonical frontend namespace: `/api/plugins/fusion-plugin-roadmap/roadmaps`
|
||||
- Roadmaps: `GET /`, `POST /`, `GET /:roadmapId`, `PATCH /:roadmapId`, `DELETE /:roadmapId`
|
||||
- Milestones: `GET /:roadmapId/milestones`, `POST /:roadmapId/milestones`, `PATCH /milestones/:milestoneId`, `DELETE /milestones/:milestoneId`, `POST /:roadmapId/milestones/reorder`
|
||||
- Features: `GET /milestones/:milestoneId/features`, `POST /milestones/:milestoneId/features`, `PATCH /features/:featureId`, `DELETE /features/:featureId`, `POST /milestones/:milestoneId/features/reorder`, `POST /features/:featureId/move`
|
||||
- Export/Handoff: `GET /:roadmapId/export`, `GET /:roadmapId/handoff`, `GET /:roadmapId/handoff/mission`, `GET /:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task`
|
||||
- Dashboard host no longer mounts legacy `/api/roadmaps`; roadmap REST traffic goes through the plugin namespace only.
|
||||
- Canonical roadmap REST namespace is plugin-scoped (`/api/plugins/fusion-plugin-roadmap/...`), while dashboard maintains a temporary `/api/roadmaps` compatibility mount that delegates to plugin-owned handlers during migration.
|
||||
|
||||
**Database schema:**
|
||||
- `roadmaps` — roadmap metadata (id, title, description, timestamps)
|
||||
@@ -738,7 +738,7 @@ Key server capabilities:
|
||||
- Insights routes (`insights-routes.ts`)
|
||||
- Evals routes (`evals-routes.ts`) — `/api/evals` read surface for eval result listing/filtering, drill-down detail, and eval run metadata
|
||||
- Research routes (`research-routes.ts`) — `/api/research` surface for runs, details, cancel/retry, exports, create-task, and attach-task actions; supports graceful degradation envelopes via availability payloads when capabilities are unavailable
|
||||
- Plugin-defined roadmap routes under `plugin-routes.ts` dispatch (`/api/plugins/roadmap-planner/...`)
|
||||
- Plugin-defined roadmap routes under `plugin-routes.ts` dispatch (`/api/plugins/fusion-plugin-roadmap/...`)
|
||||
- Project-scoped store reuse via `project-store-resolver.ts`
|
||||
- Rate limiting (`rate-limit.ts`)
|
||||
- Static SPA hosting (Vite build output)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- Backend roadmap ownership lives in `plugins/fusion-plugin-roadmap/**`.
|
||||
- Frontend roadmap ownership lives in `plugins/fusion-plugin-roadmap/**` (`dashboard/` and `dashboard-view` export).
|
||||
- Canonical API namespace is plugin-scoped: `/api/plugins/roadmap-planner/...`.
|
||||
- Canonical API namespace is plugin-scoped: `/api/plugins/fusion-plugin-roadmap/...`.
|
||||
- Dashboard Roadmaps view is registered via plugin dashboard view host (`registerBundledPluginViews` + `PluginDashboardViewHost`), not hardcoded app-level `RoadmapsView` import.
|
||||
- Roadmap schema initialization is plugin-owned via `hooks.onSchemaInit` (`ensureRoadmapSchema`).
|
||||
- AI suggestions use plugin-context session factory (`ctx.createAiSession` passed into suggestion generation), not direct `@fusion/engine` imports.
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
### Route contract expectations
|
||||
|
||||
- Plugin routes are expected under `/api/plugins/roadmap-planner/...` via host plugin route mounting.
|
||||
- Plugin routes are expected under `/api/plugins/fusion-plugin-roadmap/...` via host plugin route mounting.
|
||||
- Legacy `/api/roadmaps...` compatibility behavior (if present) must delegate to plugin implementation and be documented.
|
||||
|
||||
## 3) Host/plugin integration checks
|
||||
@@ -34,14 +34,14 @@
|
||||
### Integration points verified (Step 1)
|
||||
|
||||
- `plugins/fusion-plugin-roadmap/src/index.ts` declares:
|
||||
- `manifest.id = "roadmap-planner"`
|
||||
- `manifest.id = "fusion-plugin-roadmap"`
|
||||
- `hooks.onSchemaInit = ensureRoadmapSchema`
|
||||
- `routes = createRoadmapPluginRoutes()`
|
||||
- `dashboardViews` entry `{ viewId: "roadmaps", placement: "primary" }`
|
||||
- `packages/dashboard/app/plugins/registerBundledPluginViews.ts` statically registers:
|
||||
- plugin id `roadmap-planner`
|
||||
- plugin id `fusion-plugin-roadmap`
|
||||
- view id `roadmaps`
|
||||
- lazy import `@fusion-plugin-examples/roadmap/dashboard-view`
|
||||
- lazy import `@fusion-plugin-examples/fusion-plugin-roadmap/dashboard-view`
|
||||
- `packages/dashboard/app/App.tsx` renders plugin views through `PluginDashboardViewHost`.
|
||||
|
||||
## 4) Manual UI scenarios
|
||||
@@ -71,7 +71,7 @@ Notes:
|
||||
|
||||
### Step 2 backend/API verification checks
|
||||
|
||||
- `pnpm --filter @fusion-plugin-examples/roadmap test`
|
||||
- `pnpm --filter @fusion-plugin-examples/fusion-plugin-roadmap test`
|
||||
- ✅ Pass (9 files, 227 tests)
|
||||
- Coverage includes roadmap CRUD store operations, milestone/feature reorder, cross-milestone move, handoff/export mapping, deterministic ordering/lineage, route handlers, dashboard API client namespace, and AI suggestion behavior (success/error/timeout/service unavailable).
|
||||
- `pnpm --filter @fusion/dashboard exec vitest run src/__tests__/roadmap-routes.routes.test.ts src/__tests__/plugin-routes-wiring.test.ts --silent=passed-only --reporter=dot`
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("CLI bundle output", () => {
|
||||
expect(content).not.toContain('"@fusion/core"');
|
||||
expect(content).not.toContain('"@fusion/dashboard"');
|
||||
expect(content).not.toContain('"@fusion/engine"');
|
||||
expect(content).not.toContain('"@fusion-plugin-examples/roadmap"');
|
||||
expect(content).not.toContain('"@fusion-plugin-examples/fusion-plugin-roadmap"');
|
||||
});
|
||||
|
||||
it("does not contain runtime memory-backend side-load imports", () => {
|
||||
@@ -187,7 +187,7 @@ describe("CLI bundle output", () => {
|
||||
|
||||
expect(existsSync(manifestPath)).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
|
||||
expect(manifest.id).toBe("roadmap-planner");
|
||||
expect(manifest.id).toBe("fusion-plugin-roadmap");
|
||||
expect(typeof manifest.name).toBe("string");
|
||||
expect(manifest.name?.length).toBeGreaterThan(0);
|
||||
|
||||
|
||||
@@ -598,7 +598,7 @@ describe("Database", () => {
|
||||
it("executes roadmap plugin schema hook to create roadmap-owned tables and indexes", async () => {
|
||||
await db.runPluginSchemaInits([
|
||||
{
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
hook: ensureRoadmapSchema,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -9,13 +9,13 @@ describe("PluginLoader.createRouteContext", () => {
|
||||
const baseStore = { getRootDir: () => "/tmp" } as any;
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: baseStore });
|
||||
const resolveProjectTaskStore = vi.fn();
|
||||
const ctx = await loader.createRouteContext("roadmap-planner", {
|
||||
const ctx = await loader.createRouteContext("fusion-plugin-roadmap", {
|
||||
taskStore: baseStore,
|
||||
settings: { ok: true },
|
||||
resolveProjectTaskStore,
|
||||
});
|
||||
|
||||
expect(ctx.pluginId).toBe("roadmap-planner");
|
||||
expect(ctx.pluginId).toBe("fusion-plugin-roadmap");
|
||||
expect(ctx.settings).toEqual({ ok: true });
|
||||
expect(ctx.resolveProjectTaskStore).toBe(resolveProjectTaskStore);
|
||||
});
|
||||
|
||||
@@ -841,7 +841,7 @@ describe("PluginUiSlotDefinition", () => {
|
||||
describe("PluginDashboardViewDefinition", () => {
|
||||
it("accepts a valid PluginDashboardViewDefinition with optional fields", () => {
|
||||
const view = {
|
||||
viewId: "roadmap-planner",
|
||||
viewId: "fusion-plugin-roadmap",
|
||||
label: "Roadmap Planner",
|
||||
componentPath: "./views/RoadmapPlanner.js",
|
||||
icon: "Map",
|
||||
@@ -850,7 +850,7 @@ describe("PluginDashboardViewDefinition", () => {
|
||||
description: "Plan milestones and slices",
|
||||
};
|
||||
|
||||
expect(view.viewId).toBe("roadmap-planner");
|
||||
expect(view.viewId).toBe("fusion-plugin-roadmap");
|
||||
expect(view.placement).toBe("overflow");
|
||||
expect(view.description).toContain("milestones");
|
||||
});
|
||||
|
||||
@@ -834,7 +834,7 @@ describe("plugin dashboard view API wrappers", () => {
|
||||
it("fetchPluginDashboardViews calls /api/plugins/dashboard-views", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [
|
||||
{
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
view: { viewId: "roadmaps", label: "Roadmaps", componentPath: "./dashboard-view" },
|
||||
},
|
||||
]));
|
||||
@@ -850,7 +850,7 @@ describe("plugin dashboard view API wrappers", () => {
|
||||
it("fetchPluginUiSlots calls /api/plugins/ui-slots and keeps slot shape", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [
|
||||
{
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
slot: {
|
||||
slotId: "task-detail-tab",
|
||||
label: "Roadmap Details",
|
||||
|
||||
@@ -411,7 +411,7 @@ describe("MobileNavBar", () => {
|
||||
experimentalFeatures={{}}
|
||||
pluginDashboardViews={[
|
||||
{
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
view: { viewId: "roadmaps", label: "Roadmaps", componentPath: "./RoadmapsView", icon: "Map", placement: "primary" },
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("useViewState", () => {
|
||||
const { result } = renderHook(() => useViewState(createOptions()));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.taskView).toBe("plugin:roadmap-planner:roadmaps");
|
||||
expect(result.current.taskView).toBe("plugin:fusion-plugin-roadmap:roadmaps");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ function isTaskView(value: string | null): value is TaskView {
|
||||
return value !== null && (isBuiltInTaskView(value) || isPluginViewId(value));
|
||||
}
|
||||
|
||||
const LEGACY_ROADMAPS_PLUGIN_VIEW = getPluginViewId("roadmap-planner", "roadmaps");
|
||||
const LEGACY_ROADMAPS_PLUGIN_VIEW = getPluginViewId("fusion-plugin-roadmap", "roadmaps");
|
||||
|
||||
function normalizeTaskView(value: TaskView): TaskView {
|
||||
return value === "devserver" ? "dev-server" : value;
|
||||
@@ -47,7 +47,7 @@ function migrateLegacyRoadmapsView(value: string): TaskView {
|
||||
if (value !== "roadmaps") {
|
||||
return "board";
|
||||
}
|
||||
return isPluginViewRegistered("roadmap-planner", "roadmaps") ? LEGACY_ROADMAPS_PLUGIN_VIEW : "board";
|
||||
return isPluginViewRegistered("fusion-plugin-roadmap", "roadmaps") ? LEGACY_ROADMAPS_PLUGIN_VIEW : "board";
|
||||
}
|
||||
|
||||
interface UseViewStateOptions {
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("pluginViewRegistry", () => {
|
||||
|
||||
it("builds plugin IDs", () => {
|
||||
expect(getPluginViewId("plugin-a", "main")).toBe("plugin:plugin-a:main");
|
||||
expect(getPluginViewId("roadmap-planner", "roadmaps")).toBe("plugin:roadmap-planner:roadmaps");
|
||||
expect(getPluginViewId("fusion-plugin-roadmap", "roadmaps")).toBe("plugin:fusion-plugin-roadmap:roadmaps");
|
||||
});
|
||||
|
||||
it("parses and validates plugin IDs", () => {
|
||||
@@ -78,11 +78,11 @@ describe("pluginViewRegistry", () => {
|
||||
expect(await screen.findByText("proj-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resolves roadmap-planner registry entry and avoids unavailable fallback", async () => {
|
||||
it("resolves fusion-plugin-roadmap registry entry and avoids unavailable fallback", async () => {
|
||||
const RoadmapsView = lazy(async () => ({ default: () => <div>Roadmaps Plugin View</div> }));
|
||||
registerPluginView("roadmap-planner", "roadmaps", RoadmapsView);
|
||||
registerPluginView("fusion-plugin-roadmap", "roadmaps", RoadmapsView);
|
||||
|
||||
render(<>{PluginDashboardViewHost({ viewId: "plugin:roadmap-planner:roadmaps" })}</>);
|
||||
render(<>{PluginDashboardViewHost({ viewId: "plugin:fusion-plugin-roadmap:roadmaps" })}</>);
|
||||
|
||||
expect(await screen.findByText("Roadmaps Plugin View")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("plugin-view-unavailable")).toBeNull();
|
||||
|
||||
@@ -15,7 +15,7 @@ vi.mock("@fusion-plugin-examples/dependency-graph/dashboard-view", () => ({
|
||||
DependencyGraphDashboardView: (...args: unknown[]) => MockDependencyGraphDashboardView(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion-plugin-examples/roadmap/dashboard-view", () => ({
|
||||
vi.mock("@fusion-plugin-examples/fusion-plugin-roadmap/dashboard-view", () => ({
|
||||
RoadmapDashboardView: (...args: unknown[]) => MockRoadmapDashboardView(...args),
|
||||
}));
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("registerBundledPluginViews", () => {
|
||||
registerBundledPluginViews();
|
||||
|
||||
expect(getPluginViewComponent("fusion-plugin-dependency-graph", "graph")).toBeTruthy();
|
||||
expect(getPluginViewComponent("roadmap-planner", "roadmaps")).toBeTruthy();
|
||||
expect(getPluginViewComponent("fusion-plugin-roadmap", "roadmaps")).toBeTruthy();
|
||||
expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "wizard")).toBeTruthy();
|
||||
expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "manage")).toBeTruthy();
|
||||
});
|
||||
@@ -56,7 +56,7 @@ describe("registerBundledPluginViews", () => {
|
||||
registerBundledPluginViews();
|
||||
|
||||
expect(isPluginViewRegistered("fusion-plugin-dependency-graph", "graph")).toBe(true);
|
||||
expect(isPluginViewRegistered("roadmap-planner", "roadmaps")).toBe(true);
|
||||
expect(isPluginViewRegistered("fusion-plugin-roadmap", "roadmaps")).toBe(true);
|
||||
expect(isPluginViewRegistered("fusion-plugin-cli-printing-press", "wizard")).toBe(true);
|
||||
expect(isPluginViewRegistered("fusion-plugin-cli-printing-press", "manage")).toBe(true);
|
||||
// Unknown plugin/view should not be registered
|
||||
|
||||
@@ -80,7 +80,7 @@ export function registerBundledPluginViews(): void {
|
||||
);
|
||||
|
||||
registerPluginView(
|
||||
"roadmap-planner",
|
||||
"fusion-plugin-roadmap",
|
||||
"roadmaps",
|
||||
lazy(loadRoadmapView),
|
||||
);
|
||||
|
||||
@@ -1193,24 +1193,24 @@ describe("plugin-defined route dispatch", () => {
|
||||
it("registers PATCH routes from plugins", () => {
|
||||
const pluginRunner = {
|
||||
getPluginRoutes: vi.fn().mockReturnValue([
|
||||
{ pluginId: "roadmap-planner", route: { method: "PATCH", path: "/roadmaps/x", handler: vi.fn() } },
|
||||
{ pluginId: "fusion-plugin-roadmap", route: { method: "PATCH", path: "/roadmaps/x", handler: vi.fn() } },
|
||||
]),
|
||||
};
|
||||
|
||||
const pluginStore = createMockPluginStore();
|
||||
const router = createPluginRouter(pluginStore, createMockPluginLoader({
|
||||
createRouteContext: vi.fn().mockResolvedValue({
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
taskStore: createMockTaskStore(),
|
||||
settings: {},
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
emitEvent: vi.fn(),
|
||||
}),
|
||||
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "roadmap-planner" } }),
|
||||
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "fusion-plugin-roadmap" } }),
|
||||
} as any), pluginRunner as any, createMockTaskStore());
|
||||
|
||||
const stack = (router as any).stack as Array<{ route?: { path: string; methods: Record<string, boolean> } }>;
|
||||
const patchRoute = stack.find((layer) => layer.route?.path === "/roadmap-planner/roadmaps/x");
|
||||
const patchRoute = stack.find((layer) => layer.route?.path === "/fusion-plugin-roadmap/roadmaps/x");
|
||||
expect(patchRoute?.route?.methods.patch).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1218,14 +1218,14 @@ describe("plugin-defined route dispatch", () => {
|
||||
const routeHandler = vi.fn().mockResolvedValue({ ok: true });
|
||||
const pluginRunner = {
|
||||
getPluginRoutes: vi.fn().mockReturnValue([
|
||||
{ pluginId: "roadmap-planner", route: { method: "POST", path: "/ctx-check", handler: routeHandler } },
|
||||
{ pluginId: "fusion-plugin-roadmap", route: { method: "POST", path: "/ctx-check", handler: routeHandler } },
|
||||
]),
|
||||
};
|
||||
const scopedPluginStore = createMockPluginStore();
|
||||
const scopedTaskStore = createMockTaskStore({ getPluginStore: vi.fn().mockReturnValue(scopedPluginStore) });
|
||||
mockGetOrCreateProjectStore.mockResolvedValue(scopedTaskStore);
|
||||
const createRouteContext = vi.fn().mockImplementation(async (_pluginId: string, overrides: any) => ({
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
taskStore: overrides.taskStore,
|
||||
settings: overrides.settings,
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
@@ -1235,7 +1235,7 @@ describe("plugin-defined route dispatch", () => {
|
||||
}));
|
||||
const pluginLoader = createMockPluginLoader({
|
||||
createRouteContext,
|
||||
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "roadmap-planner" } }),
|
||||
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "fusion-plugin-roadmap" } }),
|
||||
} as any);
|
||||
const pluginStore = createMockPluginStore();
|
||||
|
||||
@@ -1243,9 +1243,9 @@ describe("plugin-defined route dispatch", () => {
|
||||
app.use(express.json());
|
||||
app.use("/api/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner as any, createMockTaskStore()));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/plugins/roadmap-planner/ctx-check", { projectId: "proj_123" });
|
||||
const res = await REQUEST(app, "POST", "/api/plugins/fusion-plugin-roadmap/ctx-check", { projectId: "proj_123" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(createRouteContext).toHaveBeenCalledWith("roadmap-planner", expect.objectContaining({
|
||||
expect(createRouteContext).toHaveBeenCalledWith("fusion-plugin-roadmap", expect.objectContaining({
|
||||
taskStore: scopedTaskStore,
|
||||
resolveProjectTaskStore: projectStoreResolver.getOrCreateProjectStore,
|
||||
}));
|
||||
|
||||
@@ -1028,7 +1028,7 @@ describe("GET /api/plugins/dashboard-views", () => {
|
||||
it("keeps dashboard-views payload separate from ui-slots payload", async () => {
|
||||
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
view: {
|
||||
viewId: "roadmaps",
|
||||
label: "Roadmaps",
|
||||
@@ -1038,7 +1038,7 @@ describe("GET /api/plugins/dashboard-views", () => {
|
||||
]);
|
||||
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
slot: {
|
||||
slotId: "task-detail-tab",
|
||||
label: "Roadmap Details",
|
||||
@@ -1696,7 +1696,8 @@ describe("createPluginRouter plugin-defined route responses", () => {
|
||||
const res = await performGet(app, "/plugins/demo/html");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toContain("text/html");
|
||||
expect(res.text).toContain("<html><body>Hello</body></html>");
|
||||
const html = typeof res.text === "string" ? res.text : String(res.body ?? "");
|
||||
expect(html).toContain("<html><body>Hello</body></html>");
|
||||
});
|
||||
|
||||
it("propagates custom response headers", async () => {
|
||||
|
||||
@@ -4,18 +4,21 @@ import { describe, it, expect } from "vitest";
|
||||
import express from "express";
|
||||
import { registerIntegratedRouters } from "../routes/register-integrated-routers.js";
|
||||
|
||||
describe("integrated roadmap routes removed", () => {
|
||||
it("does not register a legacy /roadmaps mount", () => {
|
||||
describe("integrated roadmap routes compatibility", () => {
|
||||
it("registers a legacy /roadmaps mount that delegates to plugin routes", () => {
|
||||
const router = express.Router();
|
||||
registerIntegratedRouters({
|
||||
router,
|
||||
store: {} as never,
|
||||
});
|
||||
|
||||
const mountedPaths = (router as unknown as { stack?: Array<{ regexp?: { source?: string } }> }).stack
|
||||
?.map((layer) => layer.regexp?.source ?? "")
|
||||
?? [];
|
||||
const stack = (router as unknown as { stack?: Array<{ regexp?: { source?: string }; handle?: { stack?: Array<{ route?: { path?: string } }> } }> }).stack ?? [];
|
||||
|
||||
expect(mountedPaths.some((path) => path.includes("roadmaps"))).toBe(false);
|
||||
const hasRoadmapMount = stack.some((layer) => {
|
||||
if (layer.regexp?.source?.includes("roadmaps")) return true;
|
||||
return layer.handle?.stack?.some((nested) => typeof nested.route?.path === "string" && nested.route.path.startsWith("/roadmaps")) ?? false;
|
||||
});
|
||||
|
||||
expect(hasRoadmapMount).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { SUGGESTION_TIMEOUT_MS } from "../roadmap-suggestions.js";
|
||||
|
||||
describe("roadmap suggestions compatibility exports", () => {
|
||||
it("re-exports suggestion timeout from roadmap plugin", () => {
|
||||
expect(typeof SUGGESTION_TIMEOUT_MS).toBe("number");
|
||||
expect(SUGGESTION_TIMEOUT_MS).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
88
packages/dashboard/src/roadmap-routes.ts
Normal file
88
packages/dashboard/src/roadmap-routes.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { getCreateAiSessionFactory, type PluginContext, type TaskStore } from "@fusion/core";
|
||||
import { createRoadmapPluginRoutes } from "@fusion-plugin-examples/roadmap";
|
||||
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
||||
|
||||
function asQueryRecord(query: Request["query"]): Record<string, string | string[] | undefined> {
|
||||
return query as Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
function resolveProjectId(req: Request): string | undefined {
|
||||
const queryProjectId = req.query.projectId;
|
||||
if (typeof queryProjectId === "string" && queryProjectId.trim()) return queryProjectId;
|
||||
if (req.body && typeof req.body === "object") {
|
||||
const bodyProjectId = (req.body as { projectId?: unknown }).projectId;
|
||||
if (typeof bodyProjectId === "string" && bodyProjectId.trim()) return bodyProjectId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createRoadmapCompatibilityRouter(defaultTaskStore: TaskStore): Router {
|
||||
const router = Router();
|
||||
const routes = createRoadmapPluginRoutes();
|
||||
|
||||
for (const route of routes) {
|
||||
if (!route.path.startsWith("/roadmaps")) continue;
|
||||
|
||||
const handler = async (req: Request, res: Response): Promise<void> => {
|
||||
const projectId = resolveProjectId(req);
|
||||
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : null;
|
||||
const taskStore = scopedStore ?? defaultTaskStore;
|
||||
const createAiSession = await getCreateAiSessionFactory();
|
||||
|
||||
const ctx: PluginContext = {
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
taskStore,
|
||||
settings: {},
|
||||
logger: console,
|
||||
emitEvent: () => {},
|
||||
createAiSession,
|
||||
resolveProjectTaskStore: getOrCreateProjectStore,
|
||||
};
|
||||
|
||||
const result = await route.handler({ params: req.params as Record<string, string>, query: asQueryRecord(req.query), body: req.body }, ctx);
|
||||
|
||||
if (result && typeof result === "object" && "status" in (result as Record<string, unknown>) && typeof (result as { status?: unknown }).status === "number") {
|
||||
const response = result as { status: number; body?: unknown; headers?: Record<string, string>; contentType?: string };
|
||||
if (response.headers) {
|
||||
for (const [name, value] of Object.entries(response.headers)) {
|
||||
res.setHeader(name, value);
|
||||
}
|
||||
}
|
||||
if (response.contentType) res.setHeader("Content-Type", response.contentType);
|
||||
if (response.status === 204) {
|
||||
res.status(204).send();
|
||||
return;
|
||||
}
|
||||
if (response.body === undefined) {
|
||||
res.status(response.status).send();
|
||||
return;
|
||||
}
|
||||
res.status(response.status).json(response.body);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json(result);
|
||||
};
|
||||
|
||||
switch (route.method) {
|
||||
case "GET":
|
||||
router.get(route.path, handler);
|
||||
break;
|
||||
case "POST":
|
||||
router.post(route.path, handler);
|
||||
break;
|
||||
case "PUT":
|
||||
router.put(route.path, handler);
|
||||
break;
|
||||
case "PATCH":
|
||||
router.patch(route.path, handler);
|
||||
break;
|
||||
case "DELETE":
|
||||
router.delete(route.path, handler);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
15
packages/dashboard/src/roadmap-suggestions.ts
Normal file
15
packages/dashboard/src/roadmap-suggestions.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
FEATURE_SUGGESTION_SYSTEM_PROMPT,
|
||||
MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
ParseError,
|
||||
ServiceUnavailableError,
|
||||
SUGGESTION_TIMEOUT_MS,
|
||||
ValidationError,
|
||||
__resetSuggestionState,
|
||||
__setCreateAiSessionFactory,
|
||||
__setCreateFnAgent,
|
||||
generateFeatureSuggestions,
|
||||
generateMilestoneSuggestions,
|
||||
validateFeatureSuggestionInput,
|
||||
validateSuggestionInput,
|
||||
} from "@fusion-plugin-examples/roadmap/roadmap-suggestions";
|
||||
@@ -139,7 +139,7 @@ Integrated routers are mounted through `register-integrated-routers.ts` and inte
|
||||
- `createMissionRouter` → `/api/missions`
|
||||
- `createInsightsRouter` → `/api/insights`
|
||||
- `createTodoRouter` → `/api/todos`
|
||||
- Roadmap endpoints are plugin-owned and exposed under `/api/plugins/roadmap-planner/...`.
|
||||
- Roadmap endpoints are plugin-owned and exposed under `/api/plugins/fusion-plugin-roadmap/...`.
|
||||
- `registerIntegratedDevServerRouter(...)` mounts:
|
||||
- `createDevServerRouter` → `/api/dev-server`
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createInsightsRouter } from "../insights-routes.js";
|
||||
import { createEvalsRouter } from "../evals-routes.js";
|
||||
import { createResearchRouter } from "../research-routes.js";
|
||||
import { createTodoRouter } from "../todo-routes.js";
|
||||
import { createRoadmapCompatibilityRouter } from "../roadmap-routes.js";
|
||||
import { createDevServerRouter } from "../dev-server-routes.js";
|
||||
import type { AiSessionStore } from "../ai-session-store.js";
|
||||
import { createStashRecoveryRouter } from "./register-stash-recovery-routes.js";
|
||||
@@ -37,6 +38,7 @@ export function registerIntegratedRouters({
|
||||
router.use("/evals", createEvalsRouter(store));
|
||||
router.use("/research", createResearchRouter(store));
|
||||
router.use("/todos", createTodoRouter(store));
|
||||
router.use("/roadmaps", createRoadmapCompatibilityRouter(store));
|
||||
router.use("/stash-recovery", createStashRecoveryRouter(store));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
## Plugin identity
|
||||
|
||||
- Manifest id: `roadmap-planner`
|
||||
- Route namespace: `/api/plugins/roadmap-planner/*`
|
||||
- Dashboard view id: `plugin:roadmap-planner:roadmaps`
|
||||
- Manifest id: `fusion-plugin-roadmap`
|
||||
- Route namespace: `/api/plugins/fusion-plugin-roadmap/*`
|
||||
- Dashboard view id: `plugin:fusion-plugin-roadmap:roadmaps`
|
||||
|
||||
## Package layout
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- `src/dashboard/RoadmapsView.tsx` — plugin-owned roadmap planner page
|
||||
- `src/dashboard/useRoadmaps.ts` — plugin-owned roadmap CRUD/reorder/suggestions/handoff hook
|
||||
- `src/dashboard/RoadmapsView.css` — plugin-owned roadmap styles
|
||||
- `src/dashboard/api.ts` — plugin-local client for `/api/plugins/roadmap-planner/*`
|
||||
- `src/dashboard/api.ts` — plugin-local client for `/api/plugins/fusion-plugin-roadmap/*`
|
||||
- `src/roadmap-types.ts` + `src/store/*` — roadmap domain types/store
|
||||
|
||||
## Exported surfaces
|
||||
@@ -60,7 +60,7 @@ Host-owned responsibilities:
|
||||
|
||||
- Execute plugin schema hooks during DB startup and expose resulting tables/indexes to plugin routes.
|
||||
- Inject `createAiSession()` into plugin runtime/route context.
|
||||
- Discover plugin dashboard views via `/api/plugins/dashboard-views` and resolve plugin view IDs (for roadmap: `plugin:roadmap-planner:roadmaps`) through the host view registry.
|
||||
- Discover plugin dashboard views via `/api/plugins/dashboard-views` and resolve plugin view IDs (for roadmap: `plugin:fusion-plugin-roadmap:roadmaps`) through the host view registry.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "roadmap-planner",
|
||||
"id": "fusion-plugin-roadmap",
|
||||
"name": "Roadmaps",
|
||||
"version": "0.1.0",
|
||||
"description": "Standalone roadmap planning plugin",
|
||||
|
||||
@@ -44,11 +44,11 @@ describe("roadmap dashboard api client", () => {
|
||||
await updateRoadmap("RM-1", { title: "B" }, "proj-1");
|
||||
await deleteRoadmap("RM-1", "proj-1");
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/plugins/roadmap-planner/roadmaps?projectId=proj-1");
|
||||
expect(fetchMock.mock.calls[1][0]).toBe("/api/plugins/roadmap-planner/roadmaps/RM-1?projectId=proj-1");
|
||||
expect(fetchMock.mock.calls[2][0]).toBe("/api/plugins/roadmap-planner/roadmaps?projectId=proj-1");
|
||||
expect(fetchMock.mock.calls[3][0]).toBe("/api/plugins/roadmap-planner/roadmaps/RM-1?projectId=proj-1");
|
||||
expect(fetchMock.mock.calls[4][0]).toBe("/api/plugins/roadmap-planner/roadmaps/RM-1?projectId=proj-1");
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/plugins/fusion-plugin-roadmap/roadmaps?projectId=proj-1");
|
||||
expect(fetchMock.mock.calls[1][0]).toBe("/api/plugins/fusion-plugin-roadmap/roadmaps/RM-1?projectId=proj-1");
|
||||
expect(fetchMock.mock.calls[2][0]).toBe("/api/plugins/fusion-plugin-roadmap/roadmaps?projectId=proj-1");
|
||||
expect(fetchMock.mock.calls[3][0]).toBe("/api/plugins/fusion-plugin-roadmap/roadmaps/RM-1?projectId=proj-1");
|
||||
expect(fetchMock.mock.calls[4][0]).toBe("/api/plugins/fusion-plugin-roadmap/roadmaps/RM-1?projectId=proj-1");
|
||||
});
|
||||
|
||||
it("uses plugin namespace for milestone and feature reorder/move + suggestions + handoff", async () => {
|
||||
@@ -68,14 +68,14 @@ describe("roadmap dashboard api client", () => {
|
||||
await fetchRoadmapHandoff("RM-1", "proj-1");
|
||||
|
||||
const calledUrls = fetchMock.mock.calls.map((call) => String(call[0]));
|
||||
expect(calledUrls).toContain("/api/plugins/roadmap-planner/roadmaps/RM-1/milestones?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-1?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/roadmap-planner/roadmaps/RM-1/milestones/reorder?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-1/features/reorder?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/roadmap-planner/roadmaps/features/RF-1/move?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/roadmap-planner/roadmaps/RM-1/suggestions/milestones?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-1/suggestions/features?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/roadmap-planner/roadmaps/RM-1/handoff?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/fusion-plugin-roadmap/roadmaps/RM-1/milestones?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/fusion-plugin-roadmap/roadmaps/milestones/RMS-1?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/fusion-plugin-roadmap/roadmaps/RM-1/milestones/reorder?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/fusion-plugin-roadmap/roadmaps/milestones/RMS-1/features/reorder?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/fusion-plugin-roadmap/roadmaps/features/RF-1/move?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/fusion-plugin-roadmap/roadmaps/RM-1/suggestions/milestones?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/fusion-plugin-roadmap/roadmaps/milestones/RMS-1/suggestions/features?projectId=proj-1");
|
||||
expect(calledUrls).toContain("/api/plugins/fusion-plugin-roadmap/roadmaps/RM-1/handoff?projectId=proj-1");
|
||||
});
|
||||
|
||||
it("surfaces server error body when available", async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import plugin, {
|
||||
normalizeRoadmapMilestoneOrder,
|
||||
} from "../index.js";
|
||||
|
||||
describe("roadmap-planner package surface", () => {
|
||||
describe("fusion-plugin-roadmap package surface", () => {
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -48,7 +48,7 @@ describe("roadmap-planner package surface", () => {
|
||||
});
|
||||
|
||||
it("exports plugin manifest with roadmap id", () => {
|
||||
expect(plugin.manifest.id).toBe("roadmap-planner");
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-roadmap");
|
||||
});
|
||||
|
||||
it("registers onSchemaInit hook that creates roadmap tables and indexes", () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRoadmapPluginRoutes } from "../roadmap-routes.js";
|
||||
|
||||
function createCtx() {
|
||||
return {
|
||||
pluginId: "roadmap-planner",
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
taskStore: {
|
||||
getDatabase: () => ({}),
|
||||
getRootDir: () => "/tmp/project",
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
} from "../roadmap-types.js";
|
||||
|
||||
const BASE = "/api/plugins/roadmap-planner";
|
||||
const BASE = "/api/plugins/fusion-plugin-roadmap";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${BASE}${path}`, {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ensureRoadmapSchema } from "./roadmap-schema.js";
|
||||
|
||||
const plugin = definePlugin({
|
||||
manifest: {
|
||||
id: "roadmap-planner",
|
||||
id: "fusion-plugin-roadmap",
|
||||
name: "Roadmaps",
|
||||
version: "0.1.0",
|
||||
description: "Standalone roadmap planning plugin",
|
||||
@@ -57,17 +57,17 @@ export {
|
||||
normalizeRoadmapFeatureOrder,
|
||||
applyRoadmapFeatureReorder,
|
||||
moveRoadmapFeature,
|
||||
} from "./store/roadmap-ordering.js";
|
||||
} from "./roadmap-ordering.js";
|
||||
|
||||
export {
|
||||
mapFeatureToTaskHandoff,
|
||||
mapRoadmapToMissionHandoff,
|
||||
mapRoadmapWithHierarchyToMissionHandoff,
|
||||
mapAllFeaturesToTaskHandoffs,
|
||||
} from "./store/roadmap-handoff.js";
|
||||
} from "./roadmap-handoff.js";
|
||||
|
||||
export { RoadmapStore } from "./store/roadmap-store.js";
|
||||
export type { RoadmapStoreEvents } from "./store/roadmap-store.js";
|
||||
export { RoadmapStore } from "./roadmap-store.js";
|
||||
export type { RoadmapStoreEvents } from "./roadmap-store.js";
|
||||
|
||||
export { ensureRoadmapSchema } from "./roadmap-schema.js";
|
||||
export { RoadmapDashboardView } from "./dashboard-view.js";
|
||||
|
||||
6
plugins/fusion-plugin-roadmap/src/roadmap-handoff.ts
Normal file
6
plugins/fusion-plugin-roadmap/src/roadmap-handoff.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
mapFeatureToTaskHandoff,
|
||||
mapRoadmapToMissionHandoff,
|
||||
mapRoadmapWithHierarchyToMissionHandoff,
|
||||
mapAllFeaturesToTaskHandoffs,
|
||||
} from "./store/roadmap-handoff.js";
|
||||
7
plugins/fusion-plugin-roadmap/src/roadmap-ordering.ts
Normal file
7
plugins/fusion-plugin-roadmap/src/roadmap-ordering.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
normalizeRoadmapMilestoneOrder,
|
||||
applyRoadmapMilestoneReorder,
|
||||
normalizeRoadmapFeatureOrder,
|
||||
applyRoadmapFeatureReorder,
|
||||
moveRoadmapFeature,
|
||||
} from "./store/roadmap-ordering.js";
|
||||
2
plugins/fusion-plugin-roadmap/src/roadmap-store.ts
Normal file
2
plugins/fusion-plugin-roadmap/src/roadmap-store.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { RoadmapStore } from "./store/roadmap-store.js";
|
||||
export type { RoadmapStoreEvents } from "./store/roadmap-store.js";
|
||||
Reference in New Issue
Block a user