feat(FN-3737): add even realities integration research report

Adds a new Even Realities integration research report to the documentation.

Fusion-Task-Id: FN-3737
This commit is contained in:
Fusion
2026-05-08 10:41:44 -07:00
committed by gsxdsm
parent 7b4cd5ed25
commit d90d66538a
18 changed files with 308 additions and 70 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix: dependency graph plugin failed to load because its plugin entry imported React/dashboard modules. Split the plugin into a server-pure metadata entry and a separate `./dashboard-view` subpath so the bundled-install loader can register it without crashing.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix: dashboard board silently dropped tasks when an SSE `task:created` event was missed (e.g., during reconnect or sleep/wake). The `task:moved`, `task:updated`, and `task:merged` handlers in `useTasks` used `prev.map(...)` and skipped tasks not already in local state, so subsequent updates were no-ops. Handlers now upsert, matching `task:created`, so out-of-order or post-reconnect events make the task visible instead of dropping it.

View File

@@ -71,6 +71,7 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow
| [Codebase Improvement Audit](./codebase-improvement-audit.md) | Evidence-based technical debt and reliability gap audit with prioritized recommendations |
| [Gap Analysis](./gap-analysis.md) | System completeness analysis comparing Fusion to Paperclip feature set |
| [Agent Sandbox Research](./agent-sandboxing-research.md) | Research on agent isolation, capability enforcement, and sandboxing approaches |
| [Even Realities Integration Research (FN-3737)](./even-realities-integration-research.md) | Research summary and recommended integration topology for Even Realities glasses + Fusion |
| [Agent Gap Analysis](./agent-paperclip-gap-analysis.md) | Gap analysis for agent Paperclip integration |
| [pi-autoresearch Analysis for Fusion Port](./research/pi-autoresearch-analysis.md) | Upstream architecture/license analysis and Fusion integration mapping for autoresearch capabilities |
| [Research Hardening Preflight Baseline](./research/research-hardening-preflight.md) | Verified research subsystem baseline, lifecycle contracts, and hardening pressure points |

View File

@@ -0,0 +1,140 @@
# Even Realities Integration Research (FN-3737)
## 1. Summary & Recommendation
Recommend **Option A** for v1: a **Fusion plugin + Even companion app bridge**. The companion app (or Even Hub-hosted webview app) handles device/app bridge APIs and forwards minimal HTTP calls to Fusion (`fn serve`/dashboard API). This aligns with currently visible Even SDK positioning (WebView ↔ Even App bridge, not direct glasses→LAN HTTP) and lets Fusion reuse existing task/agent REST APIs and plugin route hosting.
## 2. Even Realities Platform Overview
### 2.1 Developer surface (SDKs, docs, supported hosts)
- Public package: `@evenrealities/even_hub_sdk` (TypeScript SDK).
- SDK describes itself as a **WebView developer bridge to Even App** and EvenHub protocol APIs.
- Available evidence indicates app/webview-hosted integration (phone-hosted app shell), not direct firmware SDK for arbitrary host networking.
- Official web property reachable: `https://www.evenrealities.com/`.
- Unverified (open): canonical developer portal URL/subdomain (common guesses like `developer.*`/`docs.*` were not resolvable in this environment).
### 2.2 On-device UI/card capabilities and limits
From `@evenrealities/even_hub_sdk` README/API docs:
- Must call `createStartUpPageContainer` before other custom UI operations.
- `containerTotalNum`: **112**.
- `textObject`: up to **8** items.
- `imageObject`: max **4** (per changelog note).
- `ListContainerProperty.itemCount`: **120**.
- Exactly one container can have `isEventCapture=1` in a page.
These are effectively the card/container budget constraints for v1 payload shaping.
### 2.3 Input modalities
Evidence in SDK API surface:
- List/text/system events via `onEvenHubEvent`.
- Audio path via `audioControl(true/false)` and `audioEvent` PCM stream delivery.
- IMU feed via `imuControl` and `sysEvent.imuData` stream.
- Launch source signal (`appMenu` vs `glassesMenu`).
Unverified (open): production gesture taxonomy, physical buttons, and official STT API availability/quality guarantees.
### 2.4 Connectivity, pairing, and auth model
- SDK framing is **WebView ↔ Even App bridge**, implying glasses traffic is mediated by the Even App host runtime.
- No authoritative evidence found for direct glasses-to-LAN HTTP sessions; treat direct host HTTP as unsupported until confirmed.
- For Fusion integration, assume companion runtime holds Fusion endpoint + API key/session token and performs authenticated API calls.
### 2.5 Notification & background-execution model
- SDK changelog references “enhanced WebView background keepalive,” suggesting background execution exists but is constrained by host mobile OS policies.
- No authoritative wake/push SLA found for third-party apps; v1 should assume polling is required.
- Unverified (open): hard background cadence caps on iOS/Android for the Even host container.
### 2.6 Distribution & policy considerations
- SDK is npm-distributed (`@evenrealities/even_hub_sdk`) and appears web-app oriented.
- v1 likely ships as companion app/web bundle rather than Fusion-only plugin.
- App-store compliance, sideload policy, and Even-specific review constraints are currently unverified.
## 3. Fusion Side: Existing APIs & Plugin Surface
### 3.1 REST endpoints reusable for v1 capabilities (table: capability → endpoint)
| Capability | Existing Fusion endpoint(s) |
|---|---|
| Board/task read | `GET /api/tasks`, `GET /api/tasks/:id` |
| Create task | `POST /api/tasks` |
| Update status | `POST /api/tasks/:id/move` |
| Task comments/quick notes | `POST /api/tasks/:id/comments` |
| Trigger agent action | `POST /api/agents/:id/runs`, `POST /api/agents/:id/heartbeat` |
| Poll run status | `GET /api/agents/:id/runs`, `GET /api/agents/:id/runs/:runId` |
| Task docs/summary fetch | `GET /api/tasks/:id/documents/:key` |
(Endpoints confirmed from `packages/dashboard/src/routes/register-task-workflow-routes.ts` and `packages/dashboard/src/routes/register-agent-runtime-routes.ts`.)
### 3.2 Plugin SDK capabilities relevant here (onSchemaInit, routes, views, createAiSession)
From `docs/PLUGIN_AUTHORING.md` and plugin SDK exports:
- `onSchemaInit` for plugin-local schema/data setup.
- Plugin routes for custom endpoints (mounted under plugin namespace via plugin route registration flow).
- Dashboard view/slot registration for pairing/config UI.
- AI/session-related context APIs are available in plugin context surface (for orchestrating Fusion-side flows rather than device-side BLE logic).
### 3.3 Auth: API key / session model reuse
Reuse existing Fusion auth model:
- Dashboard/serve API auth + daemon token model (`docs/settings-reference.md`).
- Remote/tokenized login patterns (`docs/remote-access.md`) for off-device entry links.
No new auth mechanism should be introduced for v1.
## 4. Capability Mapping (MVP)
| MVP capability | Fusion endpoint(s) | Glasses UI/card pattern | Constraints |
|---|---|---|---|
| Read board/task status | `GET /api/tasks`, `GET /api/tasks/:id` | paged list container + short detail text cards | list item limits (120 per container), text budget/legibility |
| Create task | `POST /api/tasks` | quick-capture text card + confirm action | likely needs phone text/voice assist; validation/latency feedback |
| Update task (move) | `POST /api/tasks/:id/move` | action card (Done/In Review/etc.) | avoid dense workflow options on-device |
| Quick capture note/comment | `POST /api/tasks/:id/comments` | single-input capture card | STT availability uncertain; fallback to templated snippets |
| Poll notifications | `GET /api/tasks?limit=...` + `GET /api/agents/:id/runs` | inbox/alert card with unread counters | background polling limits on host platform |
| Trigger agent actions | `POST /api/agents/:id/runs` | confirm card (“Run now”) + status follow-up | require explicit confirmation and run-state polling |
## 5. Integration Topology Options
### 5.1 Option A / B / C with pros/cons
- **A) Fusion plugin + companion bridge app (recommended)**
- Pros: matches observed Even SDK app-bridge model; keeps Fusion extensibility in-plugin; minimal core-server change.
- Cons: requires companion app ownership and release workflow.
- **B) Fusion plugin + direct glasses→host HTTP**
- Pros: simpler architecture if supported.
- Cons: currently unsupported/unverified by available SDK evidence; high risk.
- **C) External companion service only (no plugin)**
- Pros: decoupled deployment.
- Cons: weaker Fusion-native UX/config surface; harder multi-project/operator setup.
### 5.2 Recommendation and rationale
Choose **Option A**. It is the only approach consistent with currently visible Even developer surface and allows Fusion-side pairing/config, auth reuse, and optional plugin-scoped helper routes without modifying core transport/auth.
**Companion app ownership for v1:** the companion app implementation is **out of scope** for this FN-3738→FN-3747 Fusion chain; the chain should deliver Fusion plugin/API-side integration points that a companion owner can consume.
**Polling cadence recommendation (v1):**
- Task/board refresh: **3060s** via `GET /api/tasks` (or filtered variants).
- Active agent-run refresh: **1020s** while run is active via `GET /api/agents/:id/runs` and `GET /api/agents/:id/runs/:runId`.
- Idle/background mode: degrade to **60120s** to respect mobile/background constraints.
## 6. Constraints, Risks, and v1 Scope Guards
- Keep v1 strictly to task read/create/update, quick capture, polling alerts, and basic agent triggers.
- Do **not** include missions/roadmaps/search/multi-project interaction on-device in v1.
- Major risk: unresolved official documentation on direct networking/background wake; mitigate via conservative polling model.
- Payload-shaping risk: container/list/text limits require terse card design and pagination.
## 7. API/Server Gaps to Address in FN-3745
- Potential thin endpoint needed for **notification aggregation** (single compact payload for glasses) to reduce multi-call polling overhead.
- Potential plugin-scoped endpoint for **quick-capture normalization** (e.g., text template expansion, source tagging).
- If existing endpoints suffice after prototype latency tests, FN-3745 can explicitly close with “none required.”
## 8. Open Questions
1. What is the canonical official Even developer portal/docs URL for G2 app developers?
2. Is direct device/network HTTP officially supported, or only app-bridge mediated calls?
3. What are hard background polling/wake limits on iOS/Android Even host environments?
4. Is first-party STT officially exposed to third-party Even Hub apps, or must PCM be sent to external STT?
5. Are there formal payload size/rate limits for bridge messages and UI refresh frequency?
6. Who owns companion app delivery in FN-3738+ (Fusion team vs separate mobile team)?
## 9. Sources
- Even official site: https://www.evenrealities.com/
- npm package metadata: https://www.npmjs.com/package/@evenrealities/even_hub_sdk
- SDK README/API details (via npm package readme): https://www.npmjs.com/package/@evenrealities/even_hub_sdk?activeTab=readme
- Fusion plugin authoring: `docs/PLUGIN_AUTHORING.md`
- Fusion plugin lifecycle/management: `docs/plugin-management.md`
- Fusion architecture/API context: `docs/architecture.md`
- Fusion remote/auth model: `docs/remote-access.md`, `docs/settings-reference.md`
- Fusion task routes source: `packages/dashboard/src/routes/register-task-workflow-routes.ts`
- Fusion agent runtime routes source: `packages/dashboard/src/routes/register-agent-runtime-routes.ts`
- Community ecosystem signal (non-authoritative): GitHub search results for “even realities sdk” (example repos surfaced via GitHub Search API).

View File

@@ -47,7 +47,7 @@ function makeManifest(overrides?: Partial<{ id: string; version: string; name: s
{
viewId: "graph",
label: "Graph",
componentPath: "./src/DependencyGraphView.tsx",
componentPath: "./dashboard-view",
icon: "Network",
placement: "more",
order: 40,

View File

@@ -255,11 +255,18 @@ export function useTasks(options?: UseTasksOptions) {
}
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
const normalizedTask = normalizeTask(task);
setTasks((prev) =>
prev.map((t) =>
t.id === normalizedTask.id ? { ...normalizedTask, column: normalizeColumn(to, normalizedTask.column) } : t
)
);
const movedTask = { ...normalizedTask, column: normalizeColumn(to, normalizedTask.column) };
setTasks((prev) => {
const existingIndex = prev.findIndex((t) => t.id === movedTask.id);
if (existingIndex === -1) {
// SSE created event was missed (e.g., reconnect gap); upsert so the
// task becomes visible instead of being silently dropped.
return [...prev, movedTask];
}
const next = [...prev];
next[existingIndex] = movedTask;
return next;
});
lastFetchTimeMs.current = Date.now();
};
@@ -270,12 +277,18 @@ export function useTasks(options?: UseTasksOptions) {
return;
}
const incoming = normalizeTask(JSON.parse(e.data) as Task);
setTasks((prev) =>
prev.map((t) => {
if (t.id !== incoming.id) return t;
return mergeIncomingTask(t, incoming);
})
);
setTasks((prev) => {
const existingIndex = prev.findIndex((t) => t.id === incoming.id);
if (existingIndex === -1) {
return [...prev, incoming];
}
const current = prev[existingIndex]!;
const merged = mergeIncomingTask(current, incoming);
if (merged === current) return prev;
const next = [...prev];
next[existingIndex] = merged;
return next;
});
lastFetchTimeMs.current = Date.now();
};
@@ -297,11 +310,16 @@ export function useTasks(options?: UseTasksOptions) {
}
const { task }: { task: Task } = JSON.parse(e.data);
const normalizedTask = normalizeTask(task);
setTasks((prev) =>
prev.map((t) =>
t.id === normalizedTask.id ? { ...normalizedTask, column: "done" as Column } : t
)
);
const mergedTask = { ...normalizedTask, column: "done" as Column };
setTasks((prev) => {
const existingIndex = prev.findIndex((t) => t.id === mergedTask.id);
if (existingIndex === -1) {
return [...prev, mergedTask];
}
const next = [...prev];
next[existingIndex] = mergedTask;
return next;
});
};
const unsubscribe = subscribeSse(`/api/events${query}`, {

View File

@@ -9,7 +9,7 @@ import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@f
// vi.mock is hoisted
vi.mock("../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js", () => {
vi.mock("@fusion-plugin-examples/roadmap/roadmap-suggestions", () => {
// Define error classes inside the factory - these will be used by the mocked module
class MockValidationError extends Error { name = "ValidationError"; constructor(m: string) { super(m); } }
class MockParseError extends Error { name = "ParseError"; constructor(m: string) { super(m); } }

View File

@@ -10,8 +10,8 @@
"import": "./src/index.ts"
},
"./dashboard-view": {
"types": "./src/index.ts",
"import": "./src/index.ts"
"types": "./src/dashboard-view.tsx",
"import": "./src/dashboard-view.tsx"
}
},
"scripts": {

View File

@@ -3,7 +3,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi, afterEach } from "vitest";
import { definePlugin } from "@fusion/plugin-sdk";
import { validatePluginManifest } from "@fusion/core";
import plugin, { DependencyGraphDashboardView } from "../index";
import plugin from "../index";
import { DependencyGraphDashboardView } from "../dashboard-view";
import { getPluginViewId } from "../../../../packages/dashboard/app/plugins/pluginViewRegistry";
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({

View File

@@ -0,0 +1,58 @@
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { PluginLoader, PluginStore } from "@fusion/core";
import { afterEach, describe, expect, it } from "vitest";
import plugin from "../index";
const testDirs: string[] = [];
afterEach(async () => {
await Promise.all(testDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("dependency graph plugin index", () => {
it("exports node-importable plugin metadata", () => {
expect(plugin).toBeDefined();
expect(plugin.manifest.id).toBe("fusion-plugin-dependency-graph");
expect(plugin.dashboardViews?.[0]).toEqual(
expect.objectContaining({
viewId: "graph",
componentPath: "./dashboard-view",
}),
);
});
it("loads src/index.ts via Node dynamic import", async () => {
const moduleUrl = pathToFileURL(join(process.cwd(), "src/index.ts")).href;
const module = await import(moduleUrl);
expect(module.default?.manifest?.id).toBe("fusion-plugin-dependency-graph");
});
it("is loadable by PluginLoader without throwing", async () => {
const rootDir = mkdtempSync(join(tmpdir(), "fn-3737-plugin-loader-"));
testDirs.push(rootDir);
const pluginStore = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: rootDir });
await pluginStore.init();
const pluginPath = join(process.cwd(), "src/index.ts");
await pluginStore.registerPlugin({ manifest: plugin.manifest, path: pluginPath });
const loader = new PluginLoader({
pluginStore,
taskStore: { logActivity: async () => undefined } as never,
pluginDirs: [dirname(dirname(pluginPath))],
});
await loader.loadPlugin(plugin.manifest.id);
const loaded = loader.getPlugin(plugin.manifest.id);
expect(loaded?.state).toBe("started");
expect(loaded?.dashboardViews?.[0]).toEqual(
expect.objectContaining({ viewId: "graph", componentPath: "./dashboard-view" }),
);
});
});

View File

@@ -0,0 +1,19 @@
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
import { createElement } from "react";
import { DependencyGraph } from "./DependencyGraph";
function createWorkflowStepNameLookup(workflowSteps: WorkflowStep[] | undefined): ReadonlyMap<string, string> {
return new Map((workflowSteps ?? []).map((step) => [step.id, step.name] as const));
}
export function DependencyGraphDashboardView({ context }: { context?: PluginDashboardViewContext }) {
return createElement(DependencyGraph, {
tasks: context?.tasks ?? [],
projectId: context?.projectId,
workflowStepNameLookup: createWorkflowStepNameLookup(context?.workflowSteps),
onOpenDetail: context?.openTaskDetail as ((task: Task | TaskDetail) => void) | undefined,
});
}
export { DependencyGraph };

View File

@@ -1,8 +1,4 @@
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
import { definePlugin } from "@fusion/plugin-sdk";
import { createElement } from "react";
import { DependencyGraph } from "./DependencyGraph";
const plugin = definePlugin({
manifest: {
@@ -25,18 +21,4 @@ const plugin = definePlugin({
],
});
function createWorkflowStepNameLookup(workflowSteps: WorkflowStep[] | undefined): ReadonlyMap<string, string> {
return new Map((workflowSteps ?? []).map((step) => [step.id, step.name] as const));
}
export function DependencyGraphDashboardView({ context }: { context?: PluginDashboardViewContext }) {
return createElement(DependencyGraph, {
tasks: context?.tasks ?? [],
projectId: context?.projectId,
workflowStepNameLookup: createWorkflowStepNameLookup(context?.workflowSteps),
onOpenDetail: context?.openTaskDetail as ((task: Task | TaskDetail) => void) | undefined,
});
}
export default plugin;
export { DependencyGraph };

View File

@@ -1,4 +1,5 @@
import type { PluginRouteDefinition } from "@fusion/core";
import { SUGGESTION_TIMEOUT_MS } from "./roadmap-suggestions.js";
export declare function createRoadmapPluginRoutes(): PluginRouteDefinition[];
export { SUGGESTION_TIMEOUT_MS };
//# sourceMappingURL=roadmap-routes.d.ts.map

View File

@@ -1 +1 @@
{"version":3,"file":"roadmap-routes.d.ts","sourceRoot":"","sources":["roadmap-routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,qBAAqB,EAAuB,MAAM,cAAc,CAAC;AAiG9F,wBAAgB,yBAAyB,IAAI,qBAAqB,EAAE,CAyRnE;AAED,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
{"version":3,"file":"roadmap-routes.d.ts","sourceRoot":"","sources":["roadmap-routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,qBAAqB,EAAuB,MAAM,cAAc,CAAC;AAG9F,OAAO,EAQL,qBAAqB,EACtB,MAAM,0BAA0B,CAAC;AAsFlC,wBAAgB,yBAAyB,IAAI,qBAAqB,EAAE,CAyRnE;AAED,OAAO,EAAE,qBAAqB,EAAE,CAAC"}

File diff suppressed because one or more lines are too long

View File

@@ -14,6 +14,7 @@ import {
validateFeatureSuggestionInput,
validateSuggestionInput,
ValidationError as SuggestionValidationError,
SUGGESTION_TIMEOUT_MS,
} from "./roadmap-suggestions.js";
const roadmapStoreCache = new WeakMap<object, RoadmapStore>();
@@ -39,6 +40,11 @@ function asRequest(req: unknown): RouteRequest {
return req as RouteRequest;
}
function paramValue(value: string | string[] | undefined): string {
if (Array.isArray(value)) return value[0] ?? "";
return value ?? "";
}
function badRequest(message: string): PluginRouteResponse {
return { status: 400, body: { error: message } };
}
@@ -129,8 +135,8 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
method: "GET",
path: "/roadmaps/:roadmapId",
handler: routeHandler((req, _ctx, roadmapStore) => {
const roadmap = roadmapStore.getRoadmapWithHierarchy(req.params.roadmapId);
return roadmap ? roadmap : notFound(`Roadmap ${req.params.roadmapId} not found`);
const roadmap = roadmapStore.getRoadmapWithHierarchy(paramValue(req.params.roadmapId));
return roadmap ? roadmap : notFound(`Roadmap ${paramValue(req.params.roadmapId)} not found`);
}),
},
{
@@ -139,7 +145,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body as { title?: string; description?: string };
try {
return roadmapStore.updateRoadmap(req.params.roadmapId, {
return roadmapStore.updateRoadmap(paramValue(req.params.roadmapId), {
title: body.title !== undefined ? validateTitle(body.title) : undefined,
description: body.description !== undefined ? validateDescription(body.description) : undefined,
});
@@ -149,7 +155,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
}),
},
{ method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler((req, _ctx, roadmapStore) => {
roadmapStore.deleteRoadmap(req.params.roadmapId);
roadmapStore.deleteRoadmap(paramValue(req.params.roadmapId));
return noContent();
}) },
{
@@ -160,7 +166,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
try {
return {
status: 201,
body: roadmapStore.createMilestone(req.params.roadmapId, {
body: roadmapStore.createMilestone(paramValue(req.params.roadmapId), {
title: validateTitle(body?.title),
description: validateDescription(body?.description),
}),
@@ -176,7 +182,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
handler: routeHandler((req, _ctx, roadmapStore) => {
try {
const body = req.body as { orderedMilestoneIds: string[] };
roadmapStore.reorderMilestones({ roadmapId: req.params.roadmapId, orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") });
roadmapStore.reorderMilestones({ roadmapId: paramValue(req.params.roadmapId), orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") });
return noContent();
} catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
@@ -189,7 +195,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body as { title?: string; description?: string };
try {
return roadmapStore.updateMilestone(req.params.milestoneId, {
return roadmapStore.updateMilestone(paramValue(req.params.milestoneId), {
title: body.title !== undefined ? validateTitle(body.title) : undefined,
description: body.description !== undefined ? validateDescription(body.description) : undefined,
});
@@ -199,7 +205,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
}),
},
{ method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler((req, _ctx, roadmapStore) => {
roadmapStore.deleteMilestone(req.params.milestoneId);
roadmapStore.deleteMilestone(paramValue(req.params.milestoneId));
return noContent();
}) },
{
@@ -210,7 +216,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
try {
return {
status: 201,
body: roadmapStore.createFeature(req.params.milestoneId, {
body: roadmapStore.createFeature(paramValue(req.params.milestoneId), {
title: validateTitle(body?.title),
description: validateDescription(body?.description),
}),
@@ -226,9 +232,9 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
handler: routeHandler((req, _ctx, roadmapStore) => {
try {
const body = req.body as { orderedFeatureIds: string[] };
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`);
roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: req.params.milestoneId, orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") });
const milestone = roadmapStore.getMilestone(paramValue(req.params.milestoneId));
if (!milestone) return notFound(`Milestone ${paramValue(req.params.milestoneId)} not found`);
roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: paramValue(req.params.milestoneId), orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") });
return noContent();
} catch (error) {
return badRequest(error instanceof Error ? error.message : "Invalid input");
@@ -241,7 +247,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
handler: routeHandler((req, _ctx, roadmapStore) => {
const body = req.body as { title?: string; description?: string };
try {
return roadmapStore.updateFeature(req.params.featureId, {
return roadmapStore.updateFeature(paramValue(req.params.featureId), {
title: body.title !== undefined ? validateTitle(body.title) : undefined,
description: body.description !== undefined ? validateDescription(body.description) : undefined,
});
@@ -251,7 +257,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
}),
},
{ method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler((req, _ctx, roadmapStore) => {
roadmapStore.deleteFeature(req.params.featureId);
roadmapStore.deleteFeature(paramValue(req.params.featureId));
return noContent();
}) },
{
@@ -262,8 +268,8 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
if (!body?.targetMilestoneId) return badRequest("targetMilestoneId is required");
if (typeof body.targetIndex !== "number") return badRequest("targetIndex must be a number");
const feature = roadmapStore.getFeature(req.params.featureId);
if (!feature) return notFound(`Feature ${req.params.featureId} not found`);
const feature = roadmapStore.getFeature(paramValue(req.params.featureId));
if (!feature) return notFound(`Feature ${paramValue(req.params.featureId)} not found`);
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
if (!fromMilestone) return notFound(`Source milestone ${feature.milestoneId} not found`);
const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId);
@@ -271,7 +277,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
roadmapStore.moveFeature({
roadmapId: fromMilestone.roadmapId,
featureId: req.params.featureId,
featureId: paramValue(req.params.featureId),
fromMilestoneId: feature.milestoneId,
toMilestoneId: body.targetMilestoneId,
targetOrderIndex: body.targetIndex,
@@ -284,8 +290,8 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
method: "POST",
path: "/roadmaps/:roadmapId/suggestions/milestones",
handler: routeHandler(async (req, ctx, roadmapStore) => {
const roadmap = roadmapStore.getRoadmap(req.params.roadmapId);
if (!roadmap) return notFound(`Roadmap ${req.params.roadmapId} not found`);
const roadmap = roadmapStore.getRoadmap(paramValue(req.params.roadmapId));
if (!roadmap) return notFound(`Roadmap ${paramValue(req.params.roadmapId)} not found`);
try {
validateSuggestionInput(req.body);
@@ -318,8 +324,8 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
method: "POST",
path: "/roadmaps/milestones/:milestoneId/suggestions/features",
handler: routeHandler(async (req, ctx, roadmapStore) => {
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`);
const milestone = roadmapStore.getMilestone(paramValue(req.params.milestoneId));
if (!milestone) return notFound(`Milestone ${paramValue(req.params.milestoneId)} not found`);
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
if (!roadmap) return notFound(`Roadmap ${milestone.roadmapId} not found`);
@@ -360,25 +366,25 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
{
method: "GET",
path: "/roadmaps/:roadmapId/export",
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapExport(req.params.roadmapId)),
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapExport(paramValue(req.params.roadmapId))),
},
{
method: "GET",
path: "/roadmaps/:roadmapId/handoff",
handler: routeHandler((req, _ctx, roadmapStore) => ({
mission: roadmapStore.getMissionPlanningHandoff(req.params.roadmapId),
features: roadmapStore.listFeatureTaskPlanningHandoffs(req.params.roadmapId),
mission: roadmapStore.getMissionPlanningHandoff(paramValue(req.params.roadmapId)),
features: roadmapStore.listFeatureTaskPlanningHandoffs(paramValue(req.params.roadmapId)),
})),
},
{
method: "GET",
path: "/roadmaps/:roadmapId/handoff/mission",
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getMissionPlanningHandoff(req.params.roadmapId)),
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getMissionPlanningHandoff(paramValue(req.params.roadmapId))),
},
{
method: "GET",
path: "/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task",
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapFeatureHandoff(req.params.roadmapId, req.params.milestoneId, req.params.featureId)),
handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapFeatureHandoff(paramValue(req.params.roadmapId), paramValue(req.params.milestoneId), paramValue(req.params.featureId))),
},
];
}

View File

@@ -242,7 +242,7 @@ export async function generateMilestoneSuggestions(
}
})(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS),
globalThis.setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS),
),
]);
@@ -336,7 +336,7 @@ export async function generateFeatureSuggestions(
}
})(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS),
globalThis.setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS),
),
]);

View File

@@ -1 +1,3 @@
export { createRoadmapPluginRoutes } from "../routes/roadmap-routes.js";
import type { PluginRouteDefinition } from "@fusion/core";
export declare function createRoadmapPluginRoutes(): PluginRouteDefinition[];