feat(FN-3738): add fusion-plugin-even-realities-glasses plugin package with
Implements a new Fusion plugin package (`fusion-plugin-even-realities-glasses`) providing settings schema, a Fusion HTTP API client, cards, quick capture actions, a notifier, and transport stub — plus plugin routes and lifecycle hooks wired into the pi extension. The branch concludes with a small fi Fusion-Task-Id: FN-3738
This commit is contained in:
5
.changeset/fn-3738-evals-experimental-flag.md
Normal file
5
.changeset/fn-3738-evals-experimental-flag.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a new global experimental feature flag, `experimentalFeatures.evalsView`, and default it to off for Evals surfaces. When disabled, the dashboard Evals view, Settings → Scheduled Evals section, header/mobile Evals navigation entries, and in-process scheduled-eval cron execution are hidden or short-circuited. Projects already using `evalSettings.enabled` must also enable `evalsView` to expose and run scheduled eval workflows.
|
||||||
@@ -256,6 +256,8 @@ For mission planning context and handoff structure, see [Missions guide](./missi
|
|||||||
|
|
||||||
Evals view is a dedicated dashboard surface for reviewing scheduled task-evaluation output.
|
Evals view is a dedicated dashboard surface for reviewing scheduled task-evaluation output.
|
||||||
|
|
||||||
|
> Available when `experimentalFeatures.evalsView` is enabled.
|
||||||
|
|
||||||
Navigation:
|
Navigation:
|
||||||
- Desktop: **Header → More views → Evals**
|
- Desktop: **Header → More views → Evals**
|
||||||
- Mobile: **More** sheet → **Evals**
|
- Mobile: **More** sheet → **Evals**
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
> **Feature flag:** Evals surfaces are gated by `experimentalFeatures.evalsView`. When disabled, the dashboard Evals view, Settings → Scheduled Evals section, and scheduled-eval cron execution are all dormant.
|
||||||
|
|
||||||
Fusion task evaluations use one canonical 0–100 integer scoring system for three categories: `agentPerformance`, `taskOutcomeQuality`, and `processCompliance`.
|
Fusion task evaluations use one canonical 0–100 integer scoring system for three categories: `agentPerformance`, `taskOutcomeQuality`, and `processCompliance`.
|
||||||
|
|
||||||
Authoritative score math lives in `packages/core/src/eval-scoring.ts`. AI output is advisory input only.
|
Authoritative score math lives in `packages/core/src/eval-scoring.ts`. AI output is advisory input only.
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
|||||||
| `researchGlobalMaxSearchResults` | `number` | `undefined` | Maximum search results per provider query. |
|
| `researchGlobalMaxSearchResults` | `number` | `undefined` | Maximum search results per provider query. |
|
||||||
| `researchGlobalFetchTimeoutMs` | `number` | `30000` | Timeout for individual HTTP fetches in milliseconds. |
|
| `researchGlobalFetchTimeoutMs` | `number` | `30000` | Timeout for individual HTTP fetches in milliseconds. |
|
||||||
| `researchGlobalUserAgent` | `string` | `"FusionResearchBot/1.0"` | User-Agent header for HTTP requests made by research providers. |
|
| `researchGlobalUserAgent` | `string` | `"FusionResearchBot/1.0"` | User-Agent header for HTTP requests made by research providers. |
|
||||||
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools). |
|
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools), and `experimentalFeatures.evalsView`, which gates Evals surfaces (dashboard view, Settings → Scheduled Evals, and scheduled-eval cron execution). |
|
||||||
| `remoteAccess` | `RemoteAccessSettings` | `{ activeProvider: null, providers: {...}, tokenStrategy: {...}, lifecycle: {...} }` | Global-scoped remote access provider + token strategy configuration used by Remote Access routes and tunnel lifecycle controls. |
|
| `remoteAccess` | `RemoteAccessSettings` | `{ activeProvider: null, providers: {...}, tokenStrategy: {...}, lifecycle: {...} }` | Global-scoped remote access provider + token strategy configuration used by Remote Access routes and tunnel lifecycle controls. |
|
||||||
|
|
||||||
### Notification providers (pluggable)
|
### Notification providers (pluggable)
|
||||||
@@ -898,6 +898,7 @@ Common built-in dashboard flags include:
|
|||||||
- `devServerView`
|
- `devServerView`
|
||||||
- `todoView` (enables dashboard Todo View; see [Todo View](./todo-view.md))
|
- `todoView` (enables dashboard Todo View; see [Todo View](./todo-view.md))
|
||||||
- `researchView`
|
- `researchView`
|
||||||
|
- `evalsView` (gates Evals dashboard view, Settings → Scheduled Evals section, and scheduled-eval cron execution)
|
||||||
- `remoteAccess`
|
- `remoteAccess`
|
||||||
- `agentOnboarding` (enables the **AI Interview** option inside the New Agent dialog)
|
- `agentOnboarding` (enables the **AI Interview** option inside the New Agent dialog)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,31 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveEvalSettings } from "../eval-settings.js";
|
import { isEvalsExperimentalEnabled, resolveEvalSettings } from "../eval-settings.js";
|
||||||
|
|
||||||
|
describe("isEvalsExperimentalEnabled", () => {
|
||||||
|
it("returns false when settings are undefined", () => {
|
||||||
|
expect(isEvalsExperimentalEnabled(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when experimentalFeatures are missing", () => {
|
||||||
|
expect(isEvalsExperimentalEnabled({})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when evalsView is false", () => {
|
||||||
|
expect(
|
||||||
|
isEvalsExperimentalEnabled({
|
||||||
|
experimentalFeatures: { evalsView: false },
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when evalsView is true", () => {
|
||||||
|
expect(
|
||||||
|
isEvalsExperimentalEnabled({
|
||||||
|
experimentalFeatures: { evalsView: true },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveEvalSettings", () => {
|
describe("resolveEvalSettings", () => {
|
||||||
it("returns deterministic defaults when eval settings are unset", () => {
|
it("returns deterministic defaults when eval settings are unset", () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isExperimentalFeatureEnabled } from "./experimental-features.js";
|
||||||
import { resolveValidatorSettingsModel } from "./model-resolution.js";
|
import { resolveValidatorSettingsModel } from "./model-resolution.js";
|
||||||
import type { ResolvedEvalSettings, Settings } from "./types.js";
|
import type { ResolvedEvalSettings, Settings } from "./types.js";
|
||||||
|
|
||||||
@@ -8,6 +9,10 @@ const DEFAULT_EVAL_SETTINGS: Omit<ResolvedEvalSettings, "evaluatorProvider" | "e
|
|||||||
retentionDays: 30,
|
retentionDays: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isEvalsExperimentalEnabled(settings: Partial<Settings> | undefined): boolean {
|
||||||
|
return isExperimentalFeatureEnabled(settings, "evalsView");
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveEvalSettings(settings: Partial<Settings> | undefined): ResolvedEvalSettings {
|
export function resolveEvalSettings(settings: Partial<Settings> | undefined): ResolvedEvalSettings {
|
||||||
const scopedSettings = settings?.evalSettings;
|
const scopedSettings = settings?.evalSettings;
|
||||||
const validatorModel = resolveValidatorSettingsModel(settings);
|
const validatorModel = resolveValidatorSettingsModel(settings);
|
||||||
|
|||||||
@@ -710,7 +710,7 @@ export type {
|
|||||||
export { isExperimentalFeatureEnabled } from "./experimental-features.js";
|
export { isExperimentalFeatureEnabled } from "./experimental-features.js";
|
||||||
export { isResearchExperimentalEnabled, resolveResearchSettings } from "./research-settings.js";
|
export { isResearchExperimentalEnabled, resolveResearchSettings } from "./research-settings.js";
|
||||||
export type { ResolvedResearchSettings } from "./research-settings.js";
|
export type { ResolvedResearchSettings } from "./research-settings.js";
|
||||||
export { resolveEvalSettings } from "./eval-settings.js";
|
export { isEvalsExperimentalEnabled, resolveEvalSettings } from "./eval-settings.js";
|
||||||
|
|
||||||
export { TodoStore } from "./todo-store.js";
|
export { TodoStore } from "./todo-store.js";
|
||||||
export type { TodoStoreEvents } from "./todo-store.js";
|
export type { TodoStoreEvents } from "./todo-store.js";
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ function AppInner() {
|
|||||||
const skillsEnabled = experimentalFeatures.skillsView === true;
|
const skillsEnabled = experimentalFeatures.skillsView === true;
|
||||||
const nodesEnabled = experimentalFeatures.nodesView === true;
|
const nodesEnabled = experimentalFeatures.nodesView === true;
|
||||||
const researchEnabled = experimentalFeatures.researchView === true;
|
const researchEnabled = experimentalFeatures.researchView === true;
|
||||||
|
const evalsEnabled = experimentalFeatures.evalsView === true;
|
||||||
const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true;
|
const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true;
|
||||||
const agentsEnabled = true;
|
const agentsEnabled = true;
|
||||||
|
|
||||||
@@ -588,7 +589,10 @@ function AppInner() {
|
|||||||
if (taskView === "research" && !researchEnabled) {
|
if (taskView === "research" && !researchEnabled) {
|
||||||
handleChangeTaskView("board");
|
handleChangeTaskView("board");
|
||||||
}
|
}
|
||||||
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, graphPluginTaskView]);
|
if (taskView === "evals" && !evalsEnabled) {
|
||||||
|
handleChangeTaskView("board");
|
||||||
|
}
|
||||||
|
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, evalsEnabled, graphPluginTaskView]);
|
||||||
|
|
||||||
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
|
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1195,6 +1199,9 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (taskView === "evals") {
|
if (taskView === "evals") {
|
||||||
|
if (!settingsLoaded || !evalsEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<PageErrorBoundary>
|
<PageErrorBoundary>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
@@ -1395,6 +1402,7 @@ function AppInner() {
|
|||||||
devServer: devServerEnabled,
|
devServer: devServerEnabled,
|
||||||
devServerView: devServerEnabled,
|
devServerView: devServerEnabled,
|
||||||
researchView: researchEnabled,
|
researchView: researchEnabled,
|
||||||
|
evalsView: evalsEnabled,
|
||||||
}}
|
}}
|
||||||
pluginDashboardViews={pluginDashboardViews}
|
pluginDashboardViews={pluginDashboardViews}
|
||||||
shellConnectionControl={
|
shellConnectionControl={
|
||||||
@@ -1501,6 +1509,7 @@ function AppInner() {
|
|||||||
devServerView: devServerEnabled,
|
devServerView: devServerEnabled,
|
||||||
todoView: todosEnabled,
|
todoView: todosEnabled,
|
||||||
researchView: researchEnabled,
|
researchView: researchEnabled,
|
||||||
|
evalsView: evalsEnabled,
|
||||||
nodesView: nodesEnabled,
|
nodesView: nodesEnabled,
|
||||||
}}
|
}}
|
||||||
pluginDashboardViews={pluginDashboardViews}
|
pluginDashboardViews={pluginDashboardViews}
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export interface HeaderProps {
|
|||||||
/** Whether the current view is a remote node */
|
/** Whether the current view is a remote node */
|
||||||
isRemote?: boolean;
|
isRemote?: boolean;
|
||||||
/** Experimental feature flags controlling visibility of nav items. */
|
/** Experimental feature flags controlling visibility of nav items. */
|
||||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean };
|
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean; evalsView?: boolean };
|
||||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||||
shellConnectionControl?: ReactNode;
|
shellConnectionControl?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -1178,7 +1178,7 @@ export function Header({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
ref={viewOverflowTriggerRef}
|
ref={viewOverflowTriggerRef}
|
||||||
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver", "graph"].includes(view) || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
|
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver", "graph"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
|
||||||
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||||
title="More views"
|
title="More views"
|
||||||
aria-label="More views"
|
aria-label="More views"
|
||||||
@@ -1195,18 +1195,20 @@ export function Header({
|
|||||||
role="menu"
|
role="menu"
|
||||||
aria-label="More views"
|
aria-label="More views"
|
||||||
>
|
>
|
||||||
<button
|
{experimentalFeatures?.evalsView && (
|
||||||
className={`view-toggle-overflow-item${view === "evals" ? " active" : ""}`}
|
<button
|
||||||
onClick={() => {
|
className={`view-toggle-overflow-item${view === "evals" ? " active" : ""}`}
|
||||||
onChangeView("evals");
|
onClick={() => {
|
||||||
setIsViewOverflowOpen(false);
|
onChangeView("evals");
|
||||||
}}
|
setIsViewOverflowOpen(false);
|
||||||
role="menuitem"
|
}}
|
||||||
data-testid="view-overflow-evals"
|
role="menuitem"
|
||||||
>
|
data-testid="view-overflow-evals"
|
||||||
<Target size={14} />
|
>
|
||||||
<span>Evals</span>
|
<Target size={14} />
|
||||||
</button>
|
<span>Evals</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{experimentalFeatures?.researchView && (
|
{experimentalFeatures?.researchView && (
|
||||||
<button
|
<button
|
||||||
className={`view-toggle-overflow-item${view === "research" ? " active" : ""}`}
|
className={`view-toggle-overflow-item${view === "research" ? " active" : ""}`}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export interface MobileNavBarProps {
|
|||||||
devServerView?: boolean;
|
devServerView?: boolean;
|
||||||
todoView?: boolean;
|
todoView?: boolean;
|
||||||
researchView?: boolean;
|
researchView?: boolean;
|
||||||
|
evalsView?: boolean;
|
||||||
nodesView?: boolean;
|
nodesView?: boolean;
|
||||||
};
|
};
|
||||||
onOpenNodes?: () => void;
|
onOpenNodes?: () => void;
|
||||||
@@ -228,7 +229,7 @@ export function MobileNavBar({
|
|||||||
|
|
||||||
const isMoreActive =
|
const isMoreActive =
|
||||||
view === "documents"
|
view === "documents"
|
||||||
|| view === "evals"
|
|| (Boolean(experimentalFeatures?.evalsView) && view === "evals")
|
||||||
|| view === "research"
|
|| view === "research"
|
||||||
|| view === "insights"
|
|| view === "insights"
|
||||||
|| view === "memory"
|
|| view === "memory"
|
||||||
@@ -610,15 +611,17 @@ export function MobileNavBar({
|
|||||||
<FileText />
|
<FileText />
|
||||||
<span>Documents</span>
|
<span>Documents</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
{experimentalFeatures?.evalsView && (
|
||||||
type="button"
|
<button
|
||||||
className="mobile-more-item"
|
type="button"
|
||||||
data-testid="mobile-more-item-evals"
|
className="mobile-more-item"
|
||||||
onClick={() => handleMoreAction(() => onChangeView("evals"))}
|
data-testid="mobile-more-item-evals"
|
||||||
>
|
onClick={() => handleMoreAction(() => onChangeView("evals"))}
|
||||||
<Target />
|
>
|
||||||
<span>Evals</span>
|
<Target />
|
||||||
</button>
|
<span>Evals</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{showSkillsInMore && (
|
{showSkillsInMore && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
|
|||||||
devServerView: "Dev Server",
|
devServerView: "Dev Server",
|
||||||
todoView: "Todo List",
|
todoView: "Todo List",
|
||||||
researchView: "Research View",
|
researchView: "Research View",
|
||||||
|
evalsView: "Evals View",
|
||||||
agentOnboarding: "Planning-style Agent Onboarding",
|
agentOnboarding: "Planning-style Agent Onboarding",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -444,6 +445,7 @@ export function SettingsModal({
|
|||||||
const experimentalFeatures = form.experimentalFeatures ?? {};
|
const experimentalFeatures = form.experimentalFeatures ?? {};
|
||||||
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
|
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
|
||||||
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
|
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
|
||||||
|
const evalsViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "evalsView");
|
||||||
const visibleSections = SETTINGS_SECTIONS.filter((section) => {
|
const visibleSections = SETTINGS_SECTIONS.filter((section) => {
|
||||||
if (section.id === "remote") {
|
if (section.id === "remote") {
|
||||||
return remoteAccessEnabled;
|
return remoteAccessEnabled;
|
||||||
@@ -453,6 +455,10 @@ export function SettingsModal({
|
|||||||
return researchViewEnabled;
|
return researchViewEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (section.id === "scheduled-evals") {
|
||||||
|
return evalsViewEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
const firstVisibleSectionId = visibleSections.find((section) => !section.isGroupHeader)?.id ?? "general";
|
const firstVisibleSectionId = visibleSections.find((section) => !section.isGroupHeader)?.id ?? "general";
|
||||||
@@ -471,10 +477,15 @@ export function SettingsModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeSection === "scheduled-evals" && !evalsViewEnabled) {
|
||||||
|
setActiveSection(firstVisibleSectionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!visibleSections.some((section) => section.id === activeSection)) {
|
if (!visibleSections.some((section) => section.id === activeSection)) {
|
||||||
setActiveSection(firstVisibleSectionId);
|
setActiveSection(firstVisibleSectionId);
|
||||||
}
|
}
|
||||||
}, [activeSection, remoteAccessEnabled, researchViewEnabled, firstVisibleSectionId, visibleSections]);
|
}, [activeSection, remoteAccessEnabled, researchViewEnabled, evalsViewEnabled, firstVisibleSectionId, visibleSections]);
|
||||||
|
|
||||||
// Auth state (independent of the settings save flow)
|
// Auth state (independent of the settings save flow)
|
||||||
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const defaultSettings: Settings = {
|
|||||||
worktreeInitCommand: "",
|
worktreeInitCommand: "",
|
||||||
testCommand: "",
|
testCommand: "",
|
||||||
buildCommand: "",
|
buildCommand: "",
|
||||||
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, memoryView: true },
|
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, memoryView: true, evalsView: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -1695,6 +1695,29 @@ describe("App view switching", () => {
|
|||||||
localStorage.removeItem("kb-dashboard-view-mode");
|
localStorage.removeItem("kb-dashboard-view-mode");
|
||||||
localStorage.removeItem(taskViewStorageKey());
|
localStorage.removeItem(taskViewStorageKey());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to board when evals view is feature-disabled", async () => {
|
||||||
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
|
localStorage.setItem(taskViewStorageKey(), "evals");
|
||||||
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
experimentalFeatures: {
|
||||||
|
...defaultSettings.experimentalFeatures,
|
||||||
|
evalsView: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.querySelector(".board")).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId("evals-view")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
localStorage.removeItem("kb-dashboard-view-mode");
|
||||||
|
localStorage.removeItem(taskViewStorageKey());
|
||||||
|
});
|
||||||
|
|
||||||
it("renders Board view by default", async () => {
|
it("renders Board view by default", async () => {
|
||||||
// Set project mode so board view is available
|
// Set project mode so board view is available
|
||||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
@@ -3702,6 +3725,7 @@ describe("App shell connection status plumbing", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host);
|
expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host);
|
||||||
|
expect(screen.getByTestId("mobile-nav-tab-more")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();
|
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();
|
||||||
|
|||||||
@@ -274,9 +274,16 @@ describe("Header", () => {
|
|||||||
expect(screen.queryByTestId("view-overflow-research")).toBeNull();
|
expect(screen.queryByTestId("view-overflow-research")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes to evals from the desktop view overflow", () => {
|
it("hides evals in the desktop view overflow when evalsView is disabled", () => {
|
||||||
|
renderHeader({ onChangeView: noop, experimentalFeatures: { evalsView: false } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||||
|
expect(screen.queryByTestId("view-overflow-evals")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes to evals from the desktop view overflow when evalsView is enabled", () => {
|
||||||
const onChangeView = vi.fn();
|
const onChangeView = vi.fn();
|
||||||
renderHeader({ onChangeView });
|
renderHeader({ onChangeView, experimentalFeatures: { evalsView: true } });
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||||
fireEvent.click(screen.getByTestId("view-overflow-evals"));
|
fireEvent.click(screen.getByTestId("view-overflow-evals"));
|
||||||
|
|||||||
@@ -488,9 +488,16 @@ describe("MobileNavBar", () => {
|
|||||||
expect(props.onChangeView).toHaveBeenCalledWith("research");
|
expect(props.onChangeView).toHaveBeenCalledWith("research");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("evals item in more sheet calls onChangeView with 'evals'", () => {
|
it("hides evals item in more sheet when evalsView is not enabled", () => {
|
||||||
|
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||||
|
expect(screen.queryByTestId("mobile-more-item-evals")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evals item in more sheet calls onChangeView with 'evals' when evalsView is enabled", () => {
|
||||||
const props = createDefaultProps();
|
const props = createDefaultProps();
|
||||||
const { container } = render(<MobileNavBar {...props} />);
|
const { container } = render(<MobileNavBar {...props} experimentalFeatures={{ evalsView: true }} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||||
fireEvent.click(screen.getByTestId("mobile-more-item-evals"));
|
fireEvent.click(screen.getByTestId("mobile-more-item-evals"));
|
||||||
|
|||||||
@@ -1887,6 +1887,14 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.getByLabelText("Research View")).toBeInTheDocument();
|
expect(screen.getByLabelText("Research View")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows evalsView in the Experimental Features list", async () => {
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
await openExperimentalFeaturesSection();
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Evals View")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows agentOnboarding in the Experimental Features list", async () => {
|
it("shows agentOnboarding in the Experimental Features list", async () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
|
|
||||||
@@ -2007,6 +2015,43 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.queryByRole("button", { name: /Research Defaults/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /Research Defaults/i })).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides scheduled evals nav item when experimentalFeatures.evalsView is disabled", async () => {
|
||||||
|
mockFetchSettings.mockResolvedValue({
|
||||||
|
...defaultSettings,
|
||||||
|
experimentalFeatures: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderModal();
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /Scheduled Evals/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows scheduled evals nav item when experimentalFeatures.evalsView is enabled", async () => {
|
||||||
|
mockFetchSettings.mockResolvedValue({
|
||||||
|
...defaultSettings,
|
||||||
|
experimentalFeatures: { evalsView: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderModal();
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /Scheduled Evals/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first selectable section when opening scheduled evals while evalsView is disabled", async () => {
|
||||||
|
mockFetchSettings.mockResolvedValue({
|
||||||
|
...defaultSettings,
|
||||||
|
experimentalFeatures: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderModal({ initialSection: "scheduled-evals" });
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /Scheduled Evals/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends canonical devServerView=false and devServer=null when disabling legacy dev server flag", async () => {
|
it("sends canonical devServerView=false and devServer=null when disabling legacy dev server flag", async () => {
|
||||||
@@ -2774,6 +2819,7 @@ describe("SettingsModal", () => {
|
|||||||
it("renders controls and disables interval controls when evals are disabled", async () => {
|
it("renders controls and disables interval controls when evals are disabled", async () => {
|
||||||
mockFetchSettings.mockResolvedValueOnce({
|
mockFetchSettings.mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
|
experimentalFeatures: { evalsView: true },
|
||||||
evalSettings: {
|
evalSettings: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
intervalMs: 86_400_000,
|
intervalMs: 86_400_000,
|
||||||
@@ -2797,6 +2843,7 @@ describe("SettingsModal", () => {
|
|||||||
it("saves edited project eval settings payload", async () => {
|
it("saves edited project eval settings payload", async () => {
|
||||||
mockFetchSettings.mockResolvedValueOnce({
|
mockFetchSettings.mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
|
experimentalFeatures: { evalsView: true },
|
||||||
evalSettings: {
|
evalSettings: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
intervalMs: 86_400_000,
|
intervalMs: 86_400_000,
|
||||||
@@ -2836,6 +2883,7 @@ describe("SettingsModal", () => {
|
|||||||
it("clears evaluator provider and model as unset when left blank", async () => {
|
it("clears evaluator provider and model as unset when left blank", async () => {
|
||||||
mockFetchSettings.mockResolvedValueOnce({
|
mockFetchSettings.mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
|
experimentalFeatures: { evalsView: true },
|
||||||
evalSettings: {
|
evalSettings: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
intervalMs: 86_400_000,
|
intervalMs: 86_400_000,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
import { Router, type Request, type Response } from "express";
|
import { Router, type Request, type Response } from "express";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { getCreateAiSessionFactory, type PluginContext, type PluginRouteDefinition, type TaskStore } from "@fusion/core";
|
import { getCreateAiSessionFactory, type PluginContext, type PluginRouteDefinition, type TaskStore } from "@fusion/core";
|
||||||
|
|||||||
@@ -1855,4 +1855,45 @@ describe("CronRunner", () => {
|
|||||||
expect(isInProcessScheduledEvalCommand("echo fn eval --scheduled-batch")).toBe(false);
|
expect(isInProcessScheduledEvalCommand("echo fn eval --scheduled-batch")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("scheduled eval command feature gating", () => {
|
||||||
|
it("returns a disabled result when evalsView experimental feature is off", async () => {
|
||||||
|
const store = createMockStore({ experimentalFeatures: { evalsView: false } as Settings["experimentalFeatures"] });
|
||||||
|
const schedule = createMockSchedule({ id: "eval-off", command: "fn eval --scheduled-batch" });
|
||||||
|
runner = new CronRunner(store, createMockAutomationStore());
|
||||||
|
|
||||||
|
const runResult = await (runner as unknown as { executeLegacyCommand: (s: ScheduledTask, startedAt: string) => Promise<AutomationRunResult> })
|
||||||
|
.executeLegacyCommand(schedule, new Date().toISOString());
|
||||||
|
expect(runResult.success).toBe(false);
|
||||||
|
expect(runResult.output).toBe("evals-experimental-disabled");
|
||||||
|
expect(runResult.error).toBe("Evals experimental feature is disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not return the disabled sentinel when evalsView experimental feature is on", async () => {
|
||||||
|
const store = createMockStore({ experimentalFeatures: { evalsView: true } as Settings["experimentalFeatures"] }) as unknown as {
|
||||||
|
getEvalStore: () => {
|
||||||
|
listRuns: () => Array<{ id: string; status: string; metadata?: Record<string, unknown>; window: { until?: string } }>;
|
||||||
|
createRun: (input: { projectId: string; trigger: string; scope: string; window: { since?: string; until: string }; metadata: Record<string, unknown> }) => { id: string; startedAt?: string; window: { until: string } };
|
||||||
|
appendRunEvent: (runId: string, event: Record<string, unknown>) => void;
|
||||||
|
updateRun: (runId: string, patch: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
listTasks: (opts: { column: string }) => Promise<Array<{ id: string; column: string; createdAt: string; updatedAt: string; executionCompletedAt?: string; title: string; summary?: string }>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
store.getEvalStore = () => ({
|
||||||
|
listRuns: () => [],
|
||||||
|
createRun: () => ({ id: "run-1", window: { until: new Date().toISOString() } }),
|
||||||
|
appendRunEvent: () => {},
|
||||||
|
updateRun: () => {},
|
||||||
|
});
|
||||||
|
store.listTasks = async () => [];
|
||||||
|
|
||||||
|
const schedule = createMockSchedule({ id: "eval-on", command: "fn eval --scheduled-batch" });
|
||||||
|
runner = new CronRunner(store as unknown as TaskStore, createMockAutomationStore());
|
||||||
|
|
||||||
|
const runResult = await (runner as unknown as { executeLegacyCommand: (s: ScheduledTask, startedAt: string) => Promise<AutomationRunResult> })
|
||||||
|
.executeLegacyCommand(schedule, new Date().toISOString());
|
||||||
|
expect(runResult.output).not.toBe("evals-experimental-disabled");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
resolveProjectDefaultModel,
|
resolveProjectDefaultModel,
|
||||||
runScheduledEvalBatch,
|
runScheduledEvalBatch,
|
||||||
resolveTaskEvaluationSettings,
|
resolveTaskEvaluationSettings,
|
||||||
|
isEvalsExperimentalEnabled,
|
||||||
type TaskStore,
|
type TaskStore,
|
||||||
type AutomationStore,
|
type AutomationStore,
|
||||||
type ScheduledTask,
|
type ScheduledTask,
|
||||||
@@ -487,6 +488,15 @@ export class CronRunner {
|
|||||||
startedAt: string,
|
startedAt: string,
|
||||||
): Promise<AutomationRunResult> {
|
): Promise<AutomationRunResult> {
|
||||||
const settings = await this.store.getSettings();
|
const settings = await this.store.getSettings();
|
||||||
|
if (!isEvalsExperimentalEnabled(settings)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: "evals-experimental-disabled",
|
||||||
|
error: "Evals experimental feature is disabled",
|
||||||
|
startedAt,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
const evalSettings = resolveTaskEvaluationSettings(settings);
|
const evalSettings = resolveTaskEvaluationSettings(settings);
|
||||||
const evaluator = new HybridEvaluatorService({ cwd: this.options.workingDirectory ?? process.cwd(), store: this.store });
|
const evaluator = new HybridEvaluatorService({ cwd: this.options.workingDirectory ?? process.cwd(), store: this.store });
|
||||||
|
|
||||||
|
|||||||
44
plugins/fusion-plugin-even-realities-glasses/README.md
Normal file
44
plugins/fusion-plugin-even-realities-glasses/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Even Realities Glasses Plugin (Fusion)
|
||||||
|
|
||||||
|
`@fusion-plugin-examples/even-realities-glasses` is a standalone Fusion plugin that provides a task-centric card workflow for Even Realities glasses.
|
||||||
|
|
||||||
|
## Scope (v1)
|
||||||
|
|
||||||
|
- Read board/task status through Fusion dashboard HTTP APIs (`/api/tasks*`)
|
||||||
|
- Quick capture text into new tasks
|
||||||
|
- Polling-based task transition notifications
|
||||||
|
- Agent actions: start work (`in-progress`) and request review (`in-review`)
|
||||||
|
|
||||||
|
Out of scope in v1: missions, roadmaps, search, multi-project routing, cloud/remote deployment orchestration.
|
||||||
|
|
||||||
|
## Install (workspace local)
|
||||||
|
|
||||||
|
From repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter @fusion-plugin-examples/even-realities-glasses build
|
||||||
|
pnpm --filter @fusion-plugin-examples/even-realities-glasses test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required settings
|
||||||
|
|
||||||
|
- `fusionApiBaseUrl` (default `http://localhost:4040`)
|
||||||
|
- `fusionApiToken` (required Bearer token)
|
||||||
|
- `glassesDeviceId` (optional identifier)
|
||||||
|
- `pollingIntervalSeconds` (default 30, min 5)
|
||||||
|
- `notifyOnColumns` (default `["in-review"]`)
|
||||||
|
- `quickCaptureDefaultColumn` (default `triage`)
|
||||||
|
- `enableAgentActions` (default `true`)
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Uses `Authorization: Bearer <token>` for all API requests.
|
||||||
|
- Prefer local/self-hosted Fusion instances and avoid exposing dashboard APIs to public networks.
|
||||||
|
- Treat `fusionApiToken` as secret material and rotate regularly.
|
||||||
|
|
||||||
|
## Transport extension point
|
||||||
|
|
||||||
|
The plugin intentionally uses `GlassesTransport` + `StubGlassesTransport` for now. The real Even Realities BLE/SDK transport should be wired behind this interface.
|
||||||
|
|
||||||
|
Dependency research task FN-3737 was not available in this task runtime, so no concrete protocol implementation is included yet. Integrate the real SDK by replacing the stub transport in `src/index.ts` while keeping route + notifier behavior unchanged.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "fusion-plugin-even-realities-glasses",
|
||||||
|
"name": "Even Realities Glasses",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Task-focused card bridge between Fusion and Even Realities glasses.",
|
||||||
|
"author": "Fusion Team",
|
||||||
|
"fusionVersion": ">=0.1.0"
|
||||||
|
}
|
||||||
31
plugins/fusion-plugin-even-realities-glasses/package.json
Normal file
31
plugins/fusion-plugin-even-realities-glasses/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@fusion-plugin-examples/even-realities-glasses",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Even Realities glasses task card bridge for Fusion",
|
||||||
|
"keywords": [
|
||||||
|
"fusion-plugin",
|
||||||
|
"even-realities",
|
||||||
|
"glasses",
|
||||||
|
"tasks"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --silent=passed-only --reporter=dot"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fusion/plugin-sdk": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.5.2",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { requestReview, startWork } from "../agent-actions.js";
|
||||||
|
|
||||||
|
describe("agent actions", () => {
|
||||||
|
it("moves task to in-progress when enabled", async () => {
|
||||||
|
const moveTask = vi.fn(async () => ({ id: "FN-1", title: "Task", description: "", column: "in-progress" }));
|
||||||
|
const card = await startWork("FN-1", {
|
||||||
|
apiClient: { moveTask } as never,
|
||||||
|
enableAgentActions: true,
|
||||||
|
logger: console,
|
||||||
|
});
|
||||||
|
expect(moveTask).toHaveBeenCalledWith("FN-1", "in-progress");
|
||||||
|
expect(card?.id).toBe("task-FN-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips when disabled", async () => {
|
||||||
|
const moveTask = vi.fn();
|
||||||
|
const warn = vi.fn();
|
||||||
|
const card = await requestReview("FN-1", {
|
||||||
|
apiClient: { moveTask } as never,
|
||||||
|
enableAgentActions: false,
|
||||||
|
logger: { warn },
|
||||||
|
});
|
||||||
|
expect(card).toBeUndefined();
|
||||||
|
expect(moveTask).not.toHaveBeenCalled();
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { boardSummaryCard, notificationCard, taskToCard } from "../cards.js";
|
||||||
|
|
||||||
|
describe("cards", () => {
|
||||||
|
it("maps task to card", () => {
|
||||||
|
expect(
|
||||||
|
taskToCard({ id: "FN-1", title: "Ship", description: "desc", column: "in-review" }),
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"accentColor": "purple",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"label": "Start work",
|
||||||
|
"taskId": "FN-1",
|
||||||
|
"type": "start-work",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Request review",
|
||||||
|
"taskId": "FN-1",
|
||||||
|
"type": "request-review",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"bodyLines": [
|
||||||
|
"desc",
|
||||||
|
"Column: in-review",
|
||||||
|
],
|
||||||
|
"id": "task-FN-1",
|
||||||
|
"title": "FN-1: Ship",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates board summary", () => {
|
||||||
|
expect(boardSummaryCard({ todo: 2, done: 1 })).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"accentColor": "blue",
|
||||||
|
"bodyLines": [
|
||||||
|
"triage: 0",
|
||||||
|
"todo: 2",
|
||||||
|
"in-progress: 0",
|
||||||
|
"in-review: 0",
|
||||||
|
"done: 1",
|
||||||
|
],
|
||||||
|
"id": "board-summary",
|
||||||
|
"title": "Fusion Board Summary",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates notification cards", () => {
|
||||||
|
expect(notificationCard({ id: "FN-2", title: "Review", description: "", column: "in-review" }, "entered notify column")).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"accentColor": "purple",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"label": "Open",
|
||||||
|
"taskId": "FN-2",
|
||||||
|
"type": "request-review",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"bodyLines": [
|
||||||
|
"Review",
|
||||||
|
"Now in in-review",
|
||||||
|
"entered notify column",
|
||||||
|
],
|
||||||
|
"id": "notification-FN-2-entered notify column",
|
||||||
|
"title": "Task update: FN-2",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { FusionApiClient, FusionApiError } from "../fusion-api-client.js";
|
||||||
|
|
||||||
|
function makeResponse(status: number, body: unknown) {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FusionApiClient", () => {
|
||||||
|
it("sends auth header and parses list tasks", async () => {
|
||||||
|
const fetchImpl = vi.fn(async () => makeResponse(200, [{ id: "FN-1", title: "a", description: "d", column: "todo", status: "todo" }]));
|
||||||
|
const client = new FusionApiClient("http://localhost:4040", "secret", fetchImpl as typeof fetch);
|
||||||
|
|
||||||
|
const tasks = await client.listTasks({ column: "todo", q: "abc" });
|
||||||
|
|
||||||
|
expect(tasks).toHaveLength(1);
|
||||||
|
const [url, options] = fetchImpl.mock.calls[0]! as unknown as [string, RequestInit];
|
||||||
|
expect(url).toContain("/api/tasks?q=abc");
|
||||||
|
expect(options.headers).toMatchObject({ Authorization: "Bearer secret", "Content-Type": "application/json" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by status client-side", async () => {
|
||||||
|
const fetchImpl = vi.fn(async () =>
|
||||||
|
makeResponse(200, [
|
||||||
|
{ id: "FN-1", title: "a", description: "d", column: "todo", status: "todo" },
|
||||||
|
{ id: "FN-2", title: "b", description: "d", column: "in-review", status: "in-review" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const client = new FusionApiClient("http://localhost:4040", "secret", fetchImpl as typeof fetch);
|
||||||
|
|
||||||
|
const tasks = await client.listTasks({ status: "in-review" });
|
||||||
|
|
||||||
|
expect(tasks.map((task) => task.id)).toEqual(["FN-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes json body for create and move", async () => {
|
||||||
|
const fetchImpl = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(async () => makeResponse(200, { id: "FN-3", title: "x", description: "y", column: "triage" }))
|
||||||
|
.mockImplementationOnce(async () => makeResponse(200, { id: "FN-3", title: "x", description: "y", column: "in-progress" }));
|
||||||
|
const client = new FusionApiClient("http://localhost:4040", "secret", fetchImpl as typeof fetch);
|
||||||
|
|
||||||
|
await client.createTask({ title: "x", description: "y", column: "triage" });
|
||||||
|
await client.moveTask("FN-3", "in-progress");
|
||||||
|
|
||||||
|
const [, firstOptions] = fetchImpl.mock.calls[0]! as unknown as [string, RequestInit];
|
||||||
|
const [secondUrl, secondOptions] = fetchImpl.mock.calls[1]! as unknown as [string, RequestInit];
|
||||||
|
expect(firstOptions.body).toBe(JSON.stringify({ title: "x", description: "y", column: "triage" }));
|
||||||
|
expect(secondUrl).toContain("/api/tasks/FN-3/move");
|
||||||
|
expect(secondOptions.method).toBe("POST");
|
||||||
|
expect(secondOptions.body).toBe(JSON.stringify({ column: "in-progress" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps non-2xx errors", async () => {
|
||||||
|
const fetchImpl = vi.fn(async () => makeResponse(400, { error: "bad request" }));
|
||||||
|
const client = new FusionApiClient("http://localhost:4040", "secret", fetchImpl as typeof fetch);
|
||||||
|
|
||||||
|
await expect(client.getTask("FN-404")).rejects.toEqual(expect.any(FusionApiError));
|
||||||
|
await expect(client.getTask("FN-404")).rejects.toMatchObject({ status: 400, body: { error: "bad request" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import plugin from "../index.js";
|
||||||
|
|
||||||
|
describe("even realities plugin", () => {
|
||||||
|
it("has expected manifest and settings keys", () => {
|
||||||
|
expect(plugin.manifest.id).toBe("fusion-plugin-even-realities-glasses");
|
||||||
|
expect(Object.keys(plugin.manifest.settingsSchema ?? {}).sort()).toEqual([
|
||||||
|
"enableAgentActions",
|
||||||
|
"fusionApiBaseUrl",
|
||||||
|
"fusionApiToken",
|
||||||
|
"glassesDeviceId",
|
||||||
|
"notifyOnColumns",
|
||||||
|
"pollingIntervalSeconds",
|
||||||
|
"quickCaptureDefaultColumn",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates notifier dedupe table on schema init", () => {
|
||||||
|
const exec = vi.fn();
|
||||||
|
plugin.hooks?.onSchemaInit?.({ exec } as never);
|
||||||
|
expect(exec).toHaveBeenCalledWith(expect.stringContaining("CREATE TABLE IF NOT EXISTS even_realities_seen_tasks"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 503 for unknown instance routes", async () => {
|
||||||
|
const ctx = { pluginId: "unknown", settings: {}, logger: console } as never;
|
||||||
|
const statusRoute = (plugin.routes ?? []).find((route) => route.method === "GET" && route.path === "/status");
|
||||||
|
const actionRoute = (plugin.routes ?? []).find((route) => route.method === "POST" && route.path === "/actions/start-work");
|
||||||
|
|
||||||
|
const statusRes = await statusRoute?.handler({}, ctx);
|
||||||
|
const actionRes = await actionRoute?.handler({ body: { taskId: "FN-1" } }, ctx);
|
||||||
|
|
||||||
|
expect(statusRes).toMatchObject({ status: 503, body: { error: expect.any(String) } });
|
||||||
|
expect(actionRes).toMatchObject({ status: 503, body: { error: expect.any(String) } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles known instance route after load", async () => {
|
||||||
|
const db = {
|
||||||
|
exec: vi.fn(),
|
||||||
|
prepare: vi.fn(() => ({ all: () => [], run: vi.fn() })),
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
pluginId: "known",
|
||||||
|
settings: { fusionApiToken: "token", fusionApiBaseUrl: "http://localhost:4040" },
|
||||||
|
logger: console,
|
||||||
|
taskStore: { getPluginStore: () => ({ db }) },
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
await plugin.hooks?.onLoad?.(ctx);
|
||||||
|
const statusRoute = (plugin.routes ?? []).find((route) => route.method === "GET" && route.path === "/status");
|
||||||
|
const res = await statusRoute?.handler({}, ctx);
|
||||||
|
|
||||||
|
expect(res).toMatchObject({ status: 200, body: { connected: true } });
|
||||||
|
await plugin.hooks?.onUnload?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import manifest from "../../manifest.json";
|
||||||
|
import { validatePluginManifest } from "@fusion/plugin-sdk";
|
||||||
|
|
||||||
|
describe("manifest", () => {
|
||||||
|
it("is valid", () => {
|
||||||
|
expect(validatePluginManifest(manifest)).toMatchObject({ valid: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createNotifier } from "../notifier.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createDbMock(seed: Array<{ taskId: string; lastColumn: string }> = []) {
|
||||||
|
const run = vi.fn();
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
db: {
|
||||||
|
prepare: (sql: string) => ({
|
||||||
|
all: () => (sql.includes("SELECT") ? seed : []),
|
||||||
|
run,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("notifier", () => {
|
||||||
|
it("notifies only on transitions after initial load", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const listTasks = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([{ id: "FN-1", title: "Task", description: "", column: "todo" }])
|
||||||
|
.mockResolvedValueOnce([{ id: "FN-1", title: "Task", description: "", column: "in-review" }])
|
||||||
|
.mockResolvedValueOnce([{ id: "FN-1", title: "Task", description: "", column: "in-review" }]);
|
||||||
|
const pushCard = vi.fn(async () => undefined);
|
||||||
|
const { db } = createDbMock();
|
||||||
|
|
||||||
|
const notifier = createNotifier({
|
||||||
|
apiClient: { listTasks } as never,
|
||||||
|
transport: { pushCard } as never,
|
||||||
|
getSettings: () => ({ pollingIntervalMs: 1000, notifyColumns: ["in-review"] }),
|
||||||
|
logger: console,
|
||||||
|
db,
|
||||||
|
});
|
||||||
|
|
||||||
|
notifier.start();
|
||||||
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
|
||||||
|
expect(pushCard).toHaveBeenCalledTimes(1);
|
||||||
|
notifier.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hydrates from db and catches poll errors", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const listTasks = vi.fn().mockRejectedValue(new Error("boom"));
|
||||||
|
const error = vi.fn();
|
||||||
|
const { db } = createDbMock([{ taskId: "FN-1", lastColumn: "todo" }]);
|
||||||
|
const notifier = createNotifier({
|
||||||
|
apiClient: { listTasks } as never,
|
||||||
|
transport: { pushCard: vi.fn() } as never,
|
||||||
|
getSettings: () => ({ pollingIntervalMs: 1000, notifyColumns: ["in-review"] }),
|
||||||
|
logger: { warn: vi.fn(), error },
|
||||||
|
db,
|
||||||
|
});
|
||||||
|
|
||||||
|
notifier.start();
|
||||||
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
expect(error).toHaveBeenCalled();
|
||||||
|
expect(notifier.getLastSnapshot().get("FN-1")).toBe("todo");
|
||||||
|
notifier.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { runQuickCapture } from "../quick-capture.js";
|
||||||
|
|
||||||
|
describe("runQuickCapture", () => {
|
||||||
|
it("uses first line as title and rest as description", async () => {
|
||||||
|
const createTask = vi.fn(async (input) => ({ id: "FN-1", ...input }));
|
||||||
|
const result = await runQuickCapture("Title\nDetail line", {
|
||||||
|
apiClient: { createTask } as never,
|
||||||
|
defaultColumn: "triage",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createTask).toHaveBeenCalledWith({ title: "Title", description: "Detail line", column: "triage" });
|
||||||
|
expect(result.taskId).toBe("FN-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses description fallback", async () => {
|
||||||
|
const createTask = vi.fn(async (input) => ({ id: "FN-2", ...input }));
|
||||||
|
await runQuickCapture("Only title", { apiClient: { createTask } as never, defaultColumn: "todo" });
|
||||||
|
expect(createTask).toHaveBeenCalledWith({ title: "Only title", description: "(captured from glasses)", column: "todo" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
agentActionsEnabled,
|
||||||
|
getFusionBaseUrl,
|
||||||
|
getFusionToken,
|
||||||
|
getNotifyColumns,
|
||||||
|
getPollingIntervalMs,
|
||||||
|
getQuickCaptureColumn,
|
||||||
|
} from "../settings.js";
|
||||||
|
|
||||||
|
describe("settings accessors", () => {
|
||||||
|
it("uses safe defaults", () => {
|
||||||
|
expect(getFusionBaseUrl({})).toBe("http://localhost:4040");
|
||||||
|
expect(getFusionToken({})).toBeUndefined();
|
||||||
|
expect(getPollingIntervalMs({})).toBe(30000);
|
||||||
|
expect(getNotifyColumns({})).toEqual(["in-review"]);
|
||||||
|
expect(getQuickCaptureColumn({})).toBe("triage");
|
||||||
|
expect(agentActionsEnabled({})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims string values", () => {
|
||||||
|
expect(getFusionBaseUrl({ fusionApiBaseUrl: " http://fusion.local:4040 " })).toBe("http://fusion.local:4040");
|
||||||
|
expect(getFusionToken({ fusionApiToken: " token " })).toBe("token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces polling minimum and finite values", () => {
|
||||||
|
expect(getPollingIntervalMs({ pollingIntervalSeconds: 2 })).toBe(5000);
|
||||||
|
expect(getPollingIntervalMs({ pollingIntervalSeconds: 8.9 })).toBe(8000);
|
||||||
|
expect(getPollingIntervalMs({ pollingIntervalSeconds: Number.NaN })).toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters notify columns and falls back when invalid", () => {
|
||||||
|
expect(getNotifyColumns({ notifyOnColumns: ["todo", " nope ", "in-review", 4] })).toEqual(["todo", "in-review"]);
|
||||||
|
expect(getNotifyColumns({ notifyOnColumns: ["nope"] })).toEqual(["in-review"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates quick capture column", () => {
|
||||||
|
expect(getQuickCaptureColumn({ quickCaptureDefaultColumn: "done" })).toBe("done");
|
||||||
|
expect(getQuickCaptureColumn({ quickCaptureDefaultColumn: "bad-column" })).toBe("triage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects explicit boolean for agent actions", () => {
|
||||||
|
expect(agentActionsEnabled({ enableAgentActions: false })).toBe(false);
|
||||||
|
expect(agentActionsEnabled({ enableAgentActions: true })).toBe(true);
|
||||||
|
expect(agentActionsEnabled({ enableAgentActions: "true" })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { StubGlassesTransport } from "../transport.js";
|
||||||
|
|
||||||
|
describe("StubGlassesTransport", () => {
|
||||||
|
it("records pushes in order", async () => {
|
||||||
|
const transport = new StubGlassesTransport();
|
||||||
|
await transport.pushCard({ id: "1", title: "A", bodyLines: [], accentColor: "blue" });
|
||||||
|
await transport.pushCard({ id: "2", title: "B", bodyLines: [], accentColor: "green" });
|
||||||
|
expect(transport.pushedCards.map((card) => card.id)).toEqual(["1", "2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits synthetic actions to handlers", async () => {
|
||||||
|
const transport = new StubGlassesTransport();
|
||||||
|
const handler = vi.fn();
|
||||||
|
transport.onAction(handler);
|
||||||
|
|
||||||
|
await transport.emitAction({ type: "quick-capture", text: "new task", timestamp: new Date().toISOString() });
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ type: "quick-capture" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { taskToCard, type GlassesCard } from "./cards.js";
|
||||||
|
import type { FusionApiClient } from "./fusion-api-client.js";
|
||||||
|
|
||||||
|
export async function startWork(
|
||||||
|
taskId: string,
|
||||||
|
deps: { apiClient: FusionApiClient; enableAgentActions: boolean; logger: Pick<Console, "warn"> },
|
||||||
|
): Promise<GlassesCard | undefined> {
|
||||||
|
if (!deps.enableAgentActions) {
|
||||||
|
deps.logger.warn("Agent actions are disabled; skipping start-work action");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const task = await deps.apiClient.moveTask(taskId, "in-progress");
|
||||||
|
return taskToCard(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestReview(
|
||||||
|
taskId: string,
|
||||||
|
deps: { apiClient: FusionApiClient; enableAgentActions: boolean; logger: Pick<Console, "warn"> },
|
||||||
|
): Promise<GlassesCard | undefined> {
|
||||||
|
if (!deps.enableAgentActions) {
|
||||||
|
deps.logger.warn("Agent actions are disabled; skipping request-review action");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const task = await deps.apiClient.moveTask(taskId, "in-review");
|
||||||
|
return taskToCard(task);
|
||||||
|
}
|
||||||
56
plugins/fusion-plugin-even-realities-glasses/src/cards.ts
Normal file
56
plugins/fusion-plugin-even-realities-glasses/src/cards.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { FusionTask } from "./fusion-api-client.js";
|
||||||
|
|
||||||
|
export type GlassesCardAction = {
|
||||||
|
type: "start-work" | "request-review" | "quick-capture";
|
||||||
|
taskId?: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GlassesCard = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
bodyLines: string[];
|
||||||
|
accentColor: string;
|
||||||
|
actions?: GlassesCardAction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLUMN_COLORS: Record<string, string> = {
|
||||||
|
triage: "yellow",
|
||||||
|
todo: "blue",
|
||||||
|
"in-progress": "cyan",
|
||||||
|
"in-review": "purple",
|
||||||
|
done: "green",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function taskToCard(task: FusionTask): GlassesCard {
|
||||||
|
return {
|
||||||
|
id: `task-${task.id}`,
|
||||||
|
title: `${task.id}: ${task.title}`,
|
||||||
|
bodyLines: [task.description, `Column: ${task.column}`],
|
||||||
|
accentColor: COLUMN_COLORS[task.column] ?? "blue",
|
||||||
|
actions: [
|
||||||
|
{ type: "start-work", taskId: task.id, label: "Start work" },
|
||||||
|
{ type: "request-review", taskId: task.id, label: "Request review" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function boardSummaryCard(tasksByColumn: Record<string, number>): GlassesCard {
|
||||||
|
const ordered = ["triage", "todo", "in-progress", "in-review", "done"];
|
||||||
|
return {
|
||||||
|
id: "board-summary",
|
||||||
|
title: "Fusion Board Summary",
|
||||||
|
bodyLines: ordered.map((column) => `${column}: ${tasksByColumn[column] ?? 0}`),
|
||||||
|
accentColor: "blue",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notificationCard(task: FusionTask, reason: string): GlassesCard {
|
||||||
|
return {
|
||||||
|
id: `notification-${task.id}-${reason}`,
|
||||||
|
title: `Task update: ${task.id}`,
|
||||||
|
bodyLines: [task.title, `Now in ${task.column}`, reason],
|
||||||
|
accentColor: COLUMN_COLORS[task.column] ?? "blue",
|
||||||
|
actions: [{ type: "request-review", taskId: task.id, label: "Open" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { TaskColumn } from "./settings.js";
|
||||||
|
|
||||||
|
export type FusionTask = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
column: TaskColumn;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListTasksFilter = {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
column?: TaskColumn;
|
||||||
|
status?: string;
|
||||||
|
q?: string;
|
||||||
|
includeArchived?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FusionApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly body: unknown,
|
||||||
|
) {
|
||||||
|
super(`Fusion API request failed: ${status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = typeof fetch;
|
||||||
|
|
||||||
|
export class FusionApiClient {
|
||||||
|
constructor(
|
||||||
|
private readonly baseUrl: string,
|
||||||
|
private readonly token: string,
|
||||||
|
private readonly fetchImpl: FetchLike = fetch,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listTasks(filter: ListTasksFilter = {}): Promise<FusionTask[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (typeof filter.limit === "number") params.set("limit", String(Math.floor(filter.limit)));
|
||||||
|
if (typeof filter.offset === "number") params.set("offset", String(Math.floor(filter.offset)));
|
||||||
|
if (typeof filter.q === "string" && filter.q.trim()) params.set("q", filter.q.trim());
|
||||||
|
if (typeof filter.includeArchived === "boolean") params.set("includeArchived", String(filter.includeArchived));
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
const data = await this.request<FusionTask[]>("GET", `/api/tasks${query ? `?${query}` : ""}`);
|
||||||
|
let tasks = Array.isArray(data) ? data : [];
|
||||||
|
if (filter.column) tasks = tasks.filter((task) => task.column === filter.column);
|
||||||
|
if (filter.status) tasks = tasks.filter((task) => task.status === filter.status);
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTask(id: string): Promise<FusionTask> {
|
||||||
|
return this.request<FusionTask>("GET", `/api/tasks/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTask(input: { title: string; description: string; column?: TaskColumn }): Promise<FusionTask> {
|
||||||
|
return this.request<FusionTask>("POST", "/api/tasks", input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTask(id: string, patch: Partial<Pick<FusionTask, "title" | "description" | "status">>): Promise<FusionTask> {
|
||||||
|
return this.request<FusionTask>("PATCH", `/api/tasks/${encodeURIComponent(id)}`, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveTask(id: string, column: TaskColumn): Promise<FusionTask> {
|
||||||
|
return this.request<FusionTask>("POST", `/api/tasks/${encodeURIComponent(id)}/move`, { column });
|
||||||
|
}
|
||||||
|
|
||||||
|
async retryTask(id: string): Promise<FusionTask> {
|
||||||
|
return this.request<FusionTask>("POST", `/api/tasks/${encodeURIComponent(id)}/retry`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async refineTask(id: string, feedback: string): Promise<FusionTask> {
|
||||||
|
return this.request<FusionTask>("POST", `/api/tasks/${encodeURIComponent(id)}/refine`, { feedback });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, "")}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => undefined);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new FusionApiError(response.status, payload);
|
||||||
|
}
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
165
plugins/fusion-plugin-even-realities-glasses/src/index.ts
Normal file
165
plugins/fusion-plugin-even-realities-glasses/src/index.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { definePlugin } from "@fusion/plugin-sdk";
|
||||||
|
import type { FusionPlugin, PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
|
||||||
|
import { requestReview, startWork } from "./agent-actions.js";
|
||||||
|
import { FusionApiClient } from "./fusion-api-client.js";
|
||||||
|
import { createNotifier } from "./notifier.js";
|
||||||
|
import { runQuickCapture } from "./quick-capture.js";
|
||||||
|
import {
|
||||||
|
agentActionsEnabled,
|
||||||
|
getFusionBaseUrl,
|
||||||
|
getFusionToken,
|
||||||
|
getNotifyColumns,
|
||||||
|
getPollingIntervalMs,
|
||||||
|
getQuickCaptureColumn,
|
||||||
|
settingsSchema,
|
||||||
|
} from "./settings.js";
|
||||||
|
import { StubGlassesTransport } from "./transport.js";
|
||||||
|
|
||||||
|
type PluginDb = {
|
||||||
|
exec(sql: string): void;
|
||||||
|
prepare(sql: string): {
|
||||||
|
all(...args: unknown[]): unknown;
|
||||||
|
run(...args: unknown[]): unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type PluginInstance = {
|
||||||
|
client: FusionApiClient;
|
||||||
|
transport: StubGlassesTransport;
|
||||||
|
notifier: ReturnType<typeof createNotifier>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const instances = new Map<string, PluginInstance>();
|
||||||
|
|
||||||
|
function getDbFromTaskStore(ctx: PluginContext): PluginDb {
|
||||||
|
const pluginStore = ctx.taskStore.getPluginStore();
|
||||||
|
const db = (pluginStore as unknown as { db?: PluginDb }).db;
|
||||||
|
if (!db) throw new Error("Plugin database unavailable");
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstanceOrResponse(ctx: PluginContext): { instance?: PluginInstance; error?: PluginRouteResponse } {
|
||||||
|
const instance = instances.get(ctx.pluginId);
|
||||||
|
if (!instance) return { error: { status: 503, body: { error: "Plugin instance not initialized" } } };
|
||||||
|
return { instance };
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes: PluginRouteDefinition[] = [
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/status",
|
||||||
|
handler: async (_req, ctx) => {
|
||||||
|
const { instance, error } = getInstanceOrResponse(ctx);
|
||||||
|
if (!instance) return error as PluginRouteResponse;
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
connected: instance.transport.connected,
|
||||||
|
lastPollTime: instance.notifier.getLastPollTime() ?? null,
|
||||||
|
notifyOnColumns: getNotifyColumns(ctx.settings),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/quick-capture",
|
||||||
|
handler: async (req, ctx) => {
|
||||||
|
const { instance, error } = getInstanceOrResponse(ctx);
|
||||||
|
if (!instance) return error as PluginRouteResponse;
|
||||||
|
const text = typeof (req as { body?: { text?: unknown } }).body?.text === "string" ? (req as { body?: { text?: string } }).body?.text ?? "" : "";
|
||||||
|
if (!text.trim()) return { status: 400, body: { error: "text is required" } };
|
||||||
|
const result = await runQuickCapture(text, { apiClient: instance.client, defaultColumn: getQuickCaptureColumn(ctx.settings) });
|
||||||
|
return { status: 200, body: result };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/actions/start-work",
|
||||||
|
handler: async (req, ctx) => {
|
||||||
|
const { instance, error } = getInstanceOrResponse(ctx);
|
||||||
|
if (!instance) return error as PluginRouteResponse;
|
||||||
|
const taskId = typeof (req as { body?: { taskId?: unknown } }).body?.taskId === "string" ? (req as { body?: { taskId?: string } }).body?.taskId : undefined;
|
||||||
|
if (!taskId) return { status: 400, body: { error: "taskId is required" } };
|
||||||
|
const card = await startWork(taskId, { apiClient: instance.client, enableAgentActions: agentActionsEnabled(ctx.settings), logger: ctx.logger });
|
||||||
|
return { status: 200, body: { ok: true, card: card ?? null } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/actions/request-review",
|
||||||
|
handler: async (req, ctx) => {
|
||||||
|
const { instance, error } = getInstanceOrResponse(ctx);
|
||||||
|
if (!instance) return error as PluginRouteResponse;
|
||||||
|
const taskId = typeof (req as { body?: { taskId?: unknown } }).body?.taskId === "string" ? (req as { body?: { taskId?: string } }).body?.taskId : undefined;
|
||||||
|
if (!taskId) return { status: 400, body: { error: "taskId is required" } };
|
||||||
|
const card = await requestReview(taskId, { apiClient: instance.client, enableAgentActions: agentActionsEnabled(ctx.settings), logger: ctx.logger });
|
||||||
|
return { status: 200, body: { ok: true, card: card ?? null } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/reconnect",
|
||||||
|
handler: async (_req, ctx) => {
|
||||||
|
const { instance, error } = getInstanceOrResponse(ctx);
|
||||||
|
if (!instance) return error as PluginRouteResponse;
|
||||||
|
await instance.transport.disconnect();
|
||||||
|
await instance.transport.connect();
|
||||||
|
return { status: 200, body: { ok: true } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const plugin: FusionPlugin = definePlugin({
|
||||||
|
manifest: {
|
||||||
|
id: "fusion-plugin-even-realities-glasses",
|
||||||
|
name: "Even Realities Glasses",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "Task-focused card bridge between Fusion and Even Realities glasses.",
|
||||||
|
author: "Fusion Team",
|
||||||
|
fusionVersion: ">=0.1.0",
|
||||||
|
settingsSchema,
|
||||||
|
},
|
||||||
|
state: "installed",
|
||||||
|
routes,
|
||||||
|
hooks: {
|
||||||
|
onSchemaInit: (db) => {
|
||||||
|
(db as PluginDb).exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS even_realities_seen_tasks (
|
||||||
|
taskId TEXT PRIMARY KEY,
|
||||||
|
lastColumn TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
onLoad: async (ctx) => {
|
||||||
|
const token = getFusionToken(ctx.settings);
|
||||||
|
if (!token) {
|
||||||
|
ctx.logger.warn("fusionApiToken is missing; even-realities plugin not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const db = getDbFromTaskStore(ctx);
|
||||||
|
const client = new FusionApiClient(getFusionBaseUrl(ctx.settings), token);
|
||||||
|
const transport = new StubGlassesTransport();
|
||||||
|
await transport.connect();
|
||||||
|
const notifier = createNotifier({
|
||||||
|
apiClient: client,
|
||||||
|
transport,
|
||||||
|
getSettings: () => ({ pollingIntervalMs: getPollingIntervalMs(ctx.settings), notifyColumns: getNotifyColumns(ctx.settings) }),
|
||||||
|
logger: ctx.logger,
|
||||||
|
db,
|
||||||
|
});
|
||||||
|
notifier.start();
|
||||||
|
instances.set(ctx.pluginId, { client, transport, notifier });
|
||||||
|
},
|
||||||
|
onUnload: async () => {
|
||||||
|
for (const [pluginId, instance] of instances.entries()) {
|
||||||
|
instance.notifier.stop();
|
||||||
|
await instance.transport.disconnect();
|
||||||
|
instances.delete(pluginId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
107
plugins/fusion-plugin-even-realities-glasses/src/notifier.ts
Normal file
107
plugins/fusion-plugin-even-realities-glasses/src/notifier.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { notificationCard } from "./cards.js";
|
||||||
|
import type { FusionApiClient, FusionTask } from "./fusion-api-client.js";
|
||||||
|
import type { GlassesTransport } from "./transport.js";
|
||||||
|
|
||||||
|
type NotifierSettings = { pollingIntervalMs: number; notifyColumns: string[] };
|
||||||
|
|
||||||
|
type PluginDb = {
|
||||||
|
prepare(sql: string): {
|
||||||
|
all(...args: unknown[]): unknown;
|
||||||
|
run(...args: unknown[]): unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createNotifier({
|
||||||
|
apiClient,
|
||||||
|
transport,
|
||||||
|
getSettings,
|
||||||
|
logger,
|
||||||
|
db,
|
||||||
|
now = () => new Date().toISOString(),
|
||||||
|
}: {
|
||||||
|
apiClient: FusionApiClient;
|
||||||
|
transport: GlassesTransport;
|
||||||
|
getSettings: () => NotifierSettings;
|
||||||
|
logger: Pick<Console, "warn" | "error">;
|
||||||
|
db: PluginDb;
|
||||||
|
now?: () => string;
|
||||||
|
}) {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let running = false;
|
||||||
|
let inFlight = false;
|
||||||
|
let lastPollTime: string | undefined;
|
||||||
|
let lastSnapshot = new Map<string, string>();
|
||||||
|
|
||||||
|
const hydrateSnapshot = () => {
|
||||||
|
const rows = db.prepare("SELECT taskId, lastColumn FROM even_realities_seen_tasks").all() as
|
||||||
|
| Array<{ taskId: string; lastColumn: string }>
|
||||||
|
| undefined;
|
||||||
|
for (const row of rows ?? []) {
|
||||||
|
if (typeof row.taskId === "string" && typeof row.lastColumn === "string") {
|
||||||
|
lastSnapshot.set(row.taskId, row.lastColumn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistTask = (taskId: string, lastColumn: string) => {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO even_realities_seen_tasks(taskId, lastColumn, updatedAt)
|
||||||
|
VALUES(?, ?, ?)
|
||||||
|
ON CONFLICT(taskId) DO UPDATE SET lastColumn = excluded.lastColumn, updatedAt = excluded.updatedAt
|
||||||
|
`).run(taskId, lastColumn, now());
|
||||||
|
};
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (!running || inFlight) return;
|
||||||
|
inFlight = true;
|
||||||
|
try {
|
||||||
|
const settings = getSettings();
|
||||||
|
const tasks = await apiClient.listTasks();
|
||||||
|
const notifyColumns = new Set(settings.notifyColumns);
|
||||||
|
const nextSnapshot = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
nextSnapshot.set(task.id, task.column);
|
||||||
|
const previousColumn = lastSnapshot.get(task.id);
|
||||||
|
if (previousColumn !== undefined && previousColumn !== task.column && notifyColumns.has(task.column)) {
|
||||||
|
await transport.pushCard(notificationCard(task as FusionTask, "entered notify column"));
|
||||||
|
}
|
||||||
|
persistTask(task.id, task.column);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSnapshot = nextSnapshot;
|
||||||
|
lastPollTime = now();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Notifier poll failed", error);
|
||||||
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
|
if (running) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
void poll();
|
||||||
|
}, getSettings().pollingIntervalMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
hydrateSnapshot();
|
||||||
|
void poll();
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
running = false;
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getLastPollTime() {
|
||||||
|
return lastPollTime;
|
||||||
|
},
|
||||||
|
getLastSnapshot() {
|
||||||
|
return new Map(lastSnapshot);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { taskToCard, type GlassesCard } from "./cards.js";
|
||||||
|
import type { FusionApiClient } from "./fusion-api-client.js";
|
||||||
|
import type { TaskColumn } from "./settings.js";
|
||||||
|
|
||||||
|
export async function runQuickCapture(
|
||||||
|
text: string,
|
||||||
|
deps: { apiClient: FusionApiClient; defaultColumn: TaskColumn },
|
||||||
|
): Promise<{ taskId: string; confirmationCard: GlassesCard }> {
|
||||||
|
const lines = text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
const title = lines[0] ?? "Quick capture";
|
||||||
|
const description = lines.slice(1).join("\n") || "(captured from glasses)";
|
||||||
|
|
||||||
|
const task = await deps.apiClient.createTask({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
column: deps.defaultColumn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskId: task.id,
|
||||||
|
confirmationCard: taskToCard(task),
|
||||||
|
};
|
||||||
|
}
|
||||||
95
plugins/fusion-plugin-even-realities-glasses/src/settings.ts
Normal file
95
plugins/fusion-plugin-even-realities-glasses/src/settings.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import type { PluginSettingSchema } from "@fusion/plugin-sdk";
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = "http://localhost:4040";
|
||||||
|
const DEFAULT_POLLING_INTERVAL_SECONDS = 30;
|
||||||
|
const MIN_POLLING_INTERVAL_SECONDS = 5;
|
||||||
|
const DEFAULT_NOTIFY_COLUMNS = ["in-review"];
|
||||||
|
const DEFAULT_QUICK_CAPTURE_COLUMN = "triage";
|
||||||
|
|
||||||
|
type TaskColumn = "triage" | "todo" | "in-progress" | "in-review" | "done";
|
||||||
|
|
||||||
|
const COLUMN_SET = new Set<TaskColumn>(["triage", "todo", "in-progress", "in-review", "done"]);
|
||||||
|
|
||||||
|
export const settingsSchema: Record<string, PluginSettingSchema> = {
|
||||||
|
fusionApiBaseUrl: {
|
||||||
|
type: "string",
|
||||||
|
label: "Fusion API Base URL",
|
||||||
|
defaultValue: DEFAULT_BASE_URL,
|
||||||
|
},
|
||||||
|
fusionApiToken: {
|
||||||
|
type: "password",
|
||||||
|
label: "Fusion API Token",
|
||||||
|
},
|
||||||
|
glassesDeviceId: {
|
||||||
|
type: "string",
|
||||||
|
label: "Glasses Device ID",
|
||||||
|
},
|
||||||
|
pollingIntervalSeconds: {
|
||||||
|
type: "number",
|
||||||
|
label: "Polling Interval (seconds)",
|
||||||
|
defaultValue: DEFAULT_POLLING_INTERVAL_SECONDS,
|
||||||
|
},
|
||||||
|
notifyOnColumns: {
|
||||||
|
type: "array",
|
||||||
|
label: "Notify on Columns",
|
||||||
|
itemType: "string",
|
||||||
|
defaultValue: DEFAULT_NOTIFY_COLUMNS,
|
||||||
|
},
|
||||||
|
quickCaptureDefaultColumn: {
|
||||||
|
type: "enum",
|
||||||
|
label: "Quick Capture Default Column",
|
||||||
|
enumValues: [...COLUMN_SET],
|
||||||
|
defaultValue: DEFAULT_QUICK_CAPTURE_COLUMN,
|
||||||
|
},
|
||||||
|
enableAgentActions: {
|
||||||
|
type: "boolean",
|
||||||
|
label: "Enable Agent Actions",
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSettingString(settings: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = settings[key];
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFusionBaseUrl(settings: Record<string, unknown>): string {
|
||||||
|
return getSettingString(settings, "fusionApiBaseUrl") ?? DEFAULT_BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFusionToken(settings: Record<string, unknown>): string | undefined {
|
||||||
|
return getSettingString(settings, "fusionApiToken");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPollingIntervalMs(settings: Record<string, unknown>): number {
|
||||||
|
const raw = settings.pollingIntervalSeconds;
|
||||||
|
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||||
|
return DEFAULT_POLLING_INTERVAL_SECONDS * 1000;
|
||||||
|
}
|
||||||
|
const seconds = Math.max(MIN_POLLING_INTERVAL_SECONDS, Math.floor(raw));
|
||||||
|
return seconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotifyColumns(settings: Record<string, unknown>): TaskColumn[] {
|
||||||
|
const raw = settings.notifyOnColumns;
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return [...DEFAULT_NOTIFY_COLUMNS] as TaskColumn[];
|
||||||
|
}
|
||||||
|
const columns = raw
|
||||||
|
.filter((value): value is string => typeof value === "string")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value): value is TaskColumn => COLUMN_SET.has(value as TaskColumn));
|
||||||
|
return columns.length > 0 ? columns : ([...DEFAULT_NOTIFY_COLUMNS] as TaskColumn[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuickCaptureColumn(settings: Record<string, unknown>): TaskColumn {
|
||||||
|
const raw = getSettingString(settings, "quickCaptureDefaultColumn");
|
||||||
|
return raw && COLUMN_SET.has(raw as TaskColumn) ? (raw as TaskColumn) : DEFAULT_QUICK_CAPTURE_COLUMN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentActionsEnabled(settings: Record<string, unknown>): boolean {
|
||||||
|
const raw = settings.enableAgentActions;
|
||||||
|
return typeof raw === "boolean" ? raw : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { TaskColumn };
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { GlassesCard } from "./cards.js";
|
||||||
|
|
||||||
|
export type GlassesAction = {
|
||||||
|
type: "start-work" | "request-review" | "quick-capture";
|
||||||
|
taskId?: string;
|
||||||
|
text?: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GlassesTransport {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
pushCard(card: GlassesCard): Promise<void>;
|
||||||
|
onAction(handler: (action: GlassesAction) => void | Promise<void>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StubGlassesTransport implements GlassesTransport {
|
||||||
|
private handlers: Array<(action: GlassesAction) => void | Promise<void>> = [];
|
||||||
|
public readonly pushedCards: GlassesCard[] = [];
|
||||||
|
public connected = false;
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
this.connected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushCard(card: GlassesCard): Promise<void> {
|
||||||
|
this.pushedCards.push(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
onAction(handler: (action: GlassesAction) => void | Promise<void>): void {
|
||||||
|
this.handlers.push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async emitAction(action: GlassesAction): Promise<void> {
|
||||||
|
for (const handler of this.handlers) {
|
||||||
|
await handler(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/__tests__/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -196,7 +196,7 @@ export async function generateMilestoneSuggestions(goalPrompt, count = DEFAULT_S
|
|||||||
dispose?.();
|
dispose?.();
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
|
new Promise((_, reject) => globalThis.setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
|
||||||
]);
|
]);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -263,7 +263,7 @@ export async function generateFeatureSuggestions(context, count = DEFAULT_SUGGES
|
|||||||
dispose?.();
|
dispose?.();
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
|
new Promise((_, reject) => globalThis.setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
|
||||||
]);
|
]);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -708,6 +708,22 @@ importers:
|
|||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
|
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
|
||||||
|
plugins/fusion-plugin-even-realities-glasses:
|
||||||
|
dependencies:
|
||||||
|
'@fusion/plugin-sdk':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/plugin-sdk
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^25.5.2
|
||||||
|
version: 25.5.2
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.0
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^3.2.4
|
||||||
|
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
|
||||||
plugins/fusion-plugin-hermes-runtime:
|
plugins/fusion-plugin-hermes-runtime:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fusion/plugin-sdk':
|
'@fusion/plugin-sdk':
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ packages:
|
|||||||
- "plugins/fusion-plugin-agent-browser"
|
- "plugins/fusion-plugin-agent-browser"
|
||||||
- "plugins/fusion-plugin-whatsapp-chat"
|
- "plugins/fusion-plugin-whatsapp-chat"
|
||||||
- "plugins/fusion-plugin-roadmap"
|
- "plugins/fusion-plugin-roadmap"
|
||||||
|
- "plugins/fusion-plugin-even-realities-glasses"
|
||||||
|
|||||||
Reference in New Issue
Block a user