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:
5
.changeset/FN-3737-dependency-graph-plugin-load.md
Normal file
5
.changeset/FN-3737-dependency-graph-plugin-load.md
Normal 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.
|
||||||
5
.changeset/fix-dashboard-sse-task-drop.md
Normal file
5
.changeset/fix-dashboard-sse-task-drop.md
Normal 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.
|
||||||
@@ -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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [Research Hardening Preflight Baseline](./research/research-hardening-preflight.md) | Verified research subsystem baseline, lifecycle contracts, and hardening pressure points |
|
||||||
|
|||||||
140
docs/even-realities-integration-research.md
Normal file
140
docs/even-realities-integration-research.md
Normal 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`: **1–12**.
|
||||||
|
- `textObject`: up to **8** items.
|
||||||
|
- `imageObject`: max **4** (per changelog note).
|
||||||
|
- `ListContainerProperty.itemCount`: **1–20**.
|
||||||
|
- 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 (1–20 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: **30–60s** via `GET /api/tasks` (or filtered variants).
|
||||||
|
- Active agent-run refresh: **10–20s** while run is active via `GET /api/agents/:id/runs` and `GET /api/agents/:id/runs/:runId`.
|
||||||
|
- Idle/background mode: degrade to **60–120s** 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).
|
||||||
@@ -47,7 +47,7 @@ function makeManifest(overrides?: Partial<{ id: string; version: string; name: s
|
|||||||
{
|
{
|
||||||
viewId: "graph",
|
viewId: "graph",
|
||||||
label: "Graph",
|
label: "Graph",
|
||||||
componentPath: "./src/DependencyGraphView.tsx",
|
componentPath: "./dashboard-view",
|
||||||
icon: "Network",
|
icon: "Network",
|
||||||
placement: "more",
|
placement: "more",
|
||||||
order: 40,
|
order: 40,
|
||||||
|
|||||||
@@ -255,11 +255,18 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
}
|
}
|
||||||
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
|
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
|
||||||
const normalizedTask = normalizeTask(task);
|
const normalizedTask = normalizeTask(task);
|
||||||
setTasks((prev) =>
|
const movedTask = { ...normalizedTask, column: normalizeColumn(to, normalizedTask.column) };
|
||||||
prev.map((t) =>
|
setTasks((prev) => {
|
||||||
t.id === normalizedTask.id ? { ...normalizedTask, column: normalizeColumn(to, normalizedTask.column) } : t
|
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();
|
lastFetchTimeMs.current = Date.now();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -270,12 +277,18 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const incoming = normalizeTask(JSON.parse(e.data) as Task);
|
const incoming = normalizeTask(JSON.parse(e.data) as Task);
|
||||||
setTasks((prev) =>
|
setTasks((prev) => {
|
||||||
prev.map((t) => {
|
const existingIndex = prev.findIndex((t) => t.id === incoming.id);
|
||||||
if (t.id !== incoming.id) return t;
|
if (existingIndex === -1) {
|
||||||
return mergeIncomingTask(t, incoming);
|
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();
|
lastFetchTimeMs.current = Date.now();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -297,11 +310,16 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
}
|
}
|
||||||
const { task }: { task: Task } = JSON.parse(e.data);
|
const { task }: { task: Task } = JSON.parse(e.data);
|
||||||
const normalizedTask = normalizeTask(task);
|
const normalizedTask = normalizeTask(task);
|
||||||
setTasks((prev) =>
|
const mergedTask = { ...normalizedTask, column: "done" as Column };
|
||||||
prev.map((t) =>
|
setTasks((prev) => {
|
||||||
t.id === normalizedTask.id ? { ...normalizedTask, column: "done" as Column } : t
|
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}`, {
|
const unsubscribe = subscribeSse(`/api/events${query}`, {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@f
|
|||||||
|
|
||||||
|
|
||||||
// vi.mock is hoisted
|
// 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
|
// 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 MockValidationError extends Error { name = "ValidationError"; constructor(m: string) { super(m); } }
|
||||||
class MockParseError extends Error { name = "ParseError"; constructor(m: string) { super(m); } }
|
class MockParseError extends Error { name = "ParseError"; constructor(m: string) { super(m); } }
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
"import": "./src/index.ts"
|
"import": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"./dashboard-view": {
|
"./dashboard-view": {
|
||||||
"types": "./src/index.ts",
|
"types": "./src/dashboard-view.tsx",
|
||||||
"import": "./src/index.ts"
|
"import": "./src/dashboard-view.tsx"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|||||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||||
import { definePlugin } from "@fusion/plugin-sdk";
|
import { definePlugin } from "@fusion/plugin-sdk";
|
||||||
import { validatePluginManifest } from "@fusion/core";
|
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";
|
import { getPluginViewId } from "../../../../packages/dashboard/app/plugins/pluginViewRegistry";
|
||||||
|
|
||||||
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 { definePlugin } from "@fusion/plugin-sdk";
|
||||||
import { createElement } from "react";
|
|
||||||
import { DependencyGraph } from "./DependencyGraph";
|
|
||||||
|
|
||||||
const plugin = definePlugin({
|
const plugin = definePlugin({
|
||||||
manifest: {
|
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 default plugin;
|
||||||
export { DependencyGraph };
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PluginRouteDefinition } from "@fusion/core";
|
import type { PluginRouteDefinition } from "@fusion/core";
|
||||||
|
import { SUGGESTION_TIMEOUT_MS } from "./roadmap-suggestions.js";
|
||||||
export declare function createRoadmapPluginRoutes(): PluginRouteDefinition[];
|
export declare function createRoadmapPluginRoutes(): PluginRouteDefinition[];
|
||||||
export { SUGGESTION_TIMEOUT_MS };
|
export { SUGGESTION_TIMEOUT_MS };
|
||||||
//# sourceMappingURL=roadmap-routes.d.ts.map
|
//# sourceMappingURL=roadmap-routes.d.ts.map
|
||||||
@@ -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
@@ -14,6 +14,7 @@ import {
|
|||||||
validateFeatureSuggestionInput,
|
validateFeatureSuggestionInput,
|
||||||
validateSuggestionInput,
|
validateSuggestionInput,
|
||||||
ValidationError as SuggestionValidationError,
|
ValidationError as SuggestionValidationError,
|
||||||
|
SUGGESTION_TIMEOUT_MS,
|
||||||
} from "./roadmap-suggestions.js";
|
} from "./roadmap-suggestions.js";
|
||||||
|
|
||||||
const roadmapStoreCache = new WeakMap<object, RoadmapStore>();
|
const roadmapStoreCache = new WeakMap<object, RoadmapStore>();
|
||||||
@@ -39,6 +40,11 @@ function asRequest(req: unknown): RouteRequest {
|
|||||||
return req as 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 {
|
function badRequest(message: string): PluginRouteResponse {
|
||||||
return { status: 400, body: { error: message } };
|
return { status: 400, body: { error: message } };
|
||||||
}
|
}
|
||||||
@@ -129,8 +135,8 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/roadmaps/:roadmapId",
|
path: "/roadmaps/:roadmapId",
|
||||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
const roadmap = roadmapStore.getRoadmapWithHierarchy(req.params.roadmapId);
|
const roadmap = roadmapStore.getRoadmapWithHierarchy(paramValue(req.params.roadmapId));
|
||||||
return roadmap ? roadmap : notFound(`Roadmap ${req.params.roadmapId} not found`);
|
return roadmap ? roadmap : notFound(`Roadmap ${paramValue(req.params.roadmapId)} not found`);
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -139,7 +145,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
|||||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
const body = req.body as { title?: string; description?: string };
|
const body = req.body as { title?: string; description?: string };
|
||||||
try {
|
try {
|
||||||
return roadmapStore.updateRoadmap(req.params.roadmapId, {
|
return roadmapStore.updateRoadmap(paramValue(req.params.roadmapId), {
|
||||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||||
description: body.description !== undefined ? validateDescription(body.description) : 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) => {
|
{ method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
roadmapStore.deleteRoadmap(req.params.roadmapId);
|
roadmapStore.deleteRoadmap(paramValue(req.params.roadmapId));
|
||||||
return noContent();
|
return noContent();
|
||||||
}) },
|
}) },
|
||||||
{
|
{
|
||||||
@@ -160,7 +166,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
|||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
status: 201,
|
status: 201,
|
||||||
body: roadmapStore.createMilestone(req.params.roadmapId, {
|
body: roadmapStore.createMilestone(paramValue(req.params.roadmapId), {
|
||||||
title: validateTitle(body?.title),
|
title: validateTitle(body?.title),
|
||||||
description: validateDescription(body?.description),
|
description: validateDescription(body?.description),
|
||||||
}),
|
}),
|
||||||
@@ -176,7 +182,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
|||||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body as { orderedMilestoneIds: string[] };
|
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();
|
return noContent();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
@@ -189,7 +195,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
|||||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
const body = req.body as { title?: string; description?: string };
|
const body = req.body as { title?: string; description?: string };
|
||||||
try {
|
try {
|
||||||
return roadmapStore.updateMilestone(req.params.milestoneId, {
|
return roadmapStore.updateMilestone(paramValue(req.params.milestoneId), {
|
||||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||||
description: body.description !== undefined ? validateDescription(body.description) : 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) => {
|
{ method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
roadmapStore.deleteMilestone(req.params.milestoneId);
|
roadmapStore.deleteMilestone(paramValue(req.params.milestoneId));
|
||||||
return noContent();
|
return noContent();
|
||||||
}) },
|
}) },
|
||||||
{
|
{
|
||||||
@@ -210,7 +216,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
|||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
status: 201,
|
status: 201,
|
||||||
body: roadmapStore.createFeature(req.params.milestoneId, {
|
body: roadmapStore.createFeature(paramValue(req.params.milestoneId), {
|
||||||
title: validateTitle(body?.title),
|
title: validateTitle(body?.title),
|
||||||
description: validateDescription(body?.description),
|
description: validateDescription(body?.description),
|
||||||
}),
|
}),
|
||||||
@@ -226,9 +232,9 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
|||||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body as { orderedFeatureIds: string[] };
|
const body = req.body as { orderedFeatureIds: string[] };
|
||||||
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
const milestone = roadmapStore.getMilestone(paramValue(req.params.milestoneId));
|
||||||
if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`);
|
if (!milestone) return notFound(`Milestone ${paramValue(req.params.milestoneId)} not found`);
|
||||||
roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: req.params.milestoneId, orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") });
|
roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: paramValue(req.params.milestoneId), orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") });
|
||||||
return noContent();
|
return noContent();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
return badRequest(error instanceof Error ? error.message : "Invalid input");
|
||||||
@@ -241,7 +247,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
|||||||
handler: routeHandler((req, _ctx, roadmapStore) => {
|
handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
const body = req.body as { title?: string; description?: string };
|
const body = req.body as { title?: string; description?: string };
|
||||||
try {
|
try {
|
||||||
return roadmapStore.updateFeature(req.params.featureId, {
|
return roadmapStore.updateFeature(paramValue(req.params.featureId), {
|
||||||
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
title: body.title !== undefined ? validateTitle(body.title) : undefined,
|
||||||
description: body.description !== undefined ? validateDescription(body.description) : 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) => {
|
{ method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler((req, _ctx, roadmapStore) => {
|
||||||
roadmapStore.deleteFeature(req.params.featureId);
|
roadmapStore.deleteFeature(paramValue(req.params.featureId));
|
||||||
return noContent();
|
return noContent();
|
||||||
}) },
|
}) },
|
||||||
{
|
{
|
||||||
@@ -262,8 +268,8 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
|||||||
if (!body?.targetMilestoneId) return badRequest("targetMilestoneId is required");
|
if (!body?.targetMilestoneId) return badRequest("targetMilestoneId is required");
|
||||||
if (typeof body.targetIndex !== "number") return badRequest("targetIndex must be a number");
|
if (typeof body.targetIndex !== "number") return badRequest("targetIndex must be a number");
|
||||||
|
|
||||||
const feature = roadmapStore.getFeature(req.params.featureId);
|
const feature = roadmapStore.getFeature(paramValue(req.params.featureId));
|
||||||
if (!feature) return notFound(`Feature ${req.params.featureId} not found`);
|
if (!feature) return notFound(`Feature ${paramValue(req.params.featureId)} not found`);
|
||||||
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
|
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
|
||||||
if (!fromMilestone) return notFound(`Source milestone ${feature.milestoneId} not found`);
|
if (!fromMilestone) return notFound(`Source milestone ${feature.milestoneId} not found`);
|
||||||
const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId);
|
const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId);
|
||||||
@@ -271,7 +277,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
|||||||
|
|
||||||
roadmapStore.moveFeature({
|
roadmapStore.moveFeature({
|
||||||
roadmapId: fromMilestone.roadmapId,
|
roadmapId: fromMilestone.roadmapId,
|
||||||
featureId: req.params.featureId,
|
featureId: paramValue(req.params.featureId),
|
||||||
fromMilestoneId: feature.milestoneId,
|
fromMilestoneId: feature.milestoneId,
|
||||||
toMilestoneId: body.targetMilestoneId,
|
toMilestoneId: body.targetMilestoneId,
|
||||||
targetOrderIndex: body.targetIndex,
|
targetOrderIndex: body.targetIndex,
|
||||||
@@ -284,8 +290,8 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/roadmaps/:roadmapId/suggestions/milestones",
|
path: "/roadmaps/:roadmapId/suggestions/milestones",
|
||||||
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||||
const roadmap = roadmapStore.getRoadmap(req.params.roadmapId);
|
const roadmap = roadmapStore.getRoadmap(paramValue(req.params.roadmapId));
|
||||||
if (!roadmap) return notFound(`Roadmap ${req.params.roadmapId} not found`);
|
if (!roadmap) return notFound(`Roadmap ${paramValue(req.params.roadmapId)} not found`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
validateSuggestionInput(req.body);
|
validateSuggestionInput(req.body);
|
||||||
@@ -318,8 +324,8 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/roadmaps/milestones/:milestoneId/suggestions/features",
|
path: "/roadmaps/milestones/:milestoneId/suggestions/features",
|
||||||
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
handler: routeHandler(async (req, ctx, roadmapStore) => {
|
||||||
const milestone = roadmapStore.getMilestone(req.params.milestoneId);
|
const milestone = roadmapStore.getMilestone(paramValue(req.params.milestoneId));
|
||||||
if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`);
|
if (!milestone) return notFound(`Milestone ${paramValue(req.params.milestoneId)} not found`);
|
||||||
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
|
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
|
||||||
if (!roadmap) return notFound(`Roadmap ${milestone.roadmapId} not found`);
|
if (!roadmap) return notFound(`Roadmap ${milestone.roadmapId} not found`);
|
||||||
|
|
||||||
@@ -360,25 +366,25 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] {
|
|||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/roadmaps/:roadmapId/export",
|
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",
|
method: "GET",
|
||||||
path: "/roadmaps/:roadmapId/handoff",
|
path: "/roadmaps/:roadmapId/handoff",
|
||||||
handler: routeHandler((req, _ctx, roadmapStore) => ({
|
handler: routeHandler((req, _ctx, roadmapStore) => ({
|
||||||
mission: roadmapStore.getMissionPlanningHandoff(req.params.roadmapId),
|
mission: roadmapStore.getMissionPlanningHandoff(paramValue(req.params.roadmapId)),
|
||||||
features: roadmapStore.listFeatureTaskPlanningHandoffs(req.params.roadmapId),
|
features: roadmapStore.listFeatureTaskPlanningHandoffs(paramValue(req.params.roadmapId)),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/roadmaps/:roadmapId/handoff/mission",
|
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",
|
method: "GET",
|
||||||
path: "/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task",
|
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))),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ export async function generateMilestoneSuggestions(
|
|||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
new Promise<never>((_, reject) =>
|
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) =>
|
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),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export { createRoadmapPluginRoutes } from "../routes/roadmap-routes.js";
|
import type { PluginRouteDefinition } from "@fusion/core";
|
||||||
|
|
||||||
|
export declare function createRoadmapPluginRoutes(): PluginRouteDefinition[];
|
||||||
|
|||||||
Reference in New Issue
Block a user