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.
|
||||
|
||||
> Available when `experimentalFeatures.evalsView` is enabled.
|
||||
|
||||
Navigation:
|
||||
- Desktop: **Header → More views → Evals**
|
||||
- Mobile: **More** sheet → **Evals**
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
## 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`.
|
||||
|
||||
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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
### Notification providers (pluggable)
|
||||
@@ -898,6 +898,7 @@ Common built-in dashboard flags include:
|
||||
- `devServerView`
|
||||
- `todoView` (enables dashboard Todo View; see [Todo View](./todo-view.md))
|
||||
- `researchView`
|
||||
- `evalsView` (gates Evals dashboard view, Settings → Scheduled Evals section, and scheduled-eval cron execution)
|
||||
- `remoteAccess`
|
||||
- `agentOnboarding` (enables the **AI Interview** option inside the New Agent dialog)
|
||||
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
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", () => {
|
||||
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 type { ResolvedEvalSettings, Settings } from "./types.js";
|
||||
|
||||
@@ -8,6 +9,10 @@ const DEFAULT_EVAL_SETTINGS: Omit<ResolvedEvalSettings, "evaluatorProvider" | "e
|
||||
retentionDays: 30,
|
||||
};
|
||||
|
||||
export function isEvalsExperimentalEnabled(settings: Partial<Settings> | undefined): boolean {
|
||||
return isExperimentalFeatureEnabled(settings, "evalsView");
|
||||
}
|
||||
|
||||
export function resolveEvalSettings(settings: Partial<Settings> | undefined): ResolvedEvalSettings {
|
||||
const scopedSettings = settings?.evalSettings;
|
||||
const validatorModel = resolveValidatorSettingsModel(settings);
|
||||
|
||||
@@ -710,7 +710,7 @@ export type {
|
||||
export { isExperimentalFeatureEnabled } from "./experimental-features.js";
|
||||
export { isResearchExperimentalEnabled, resolveResearchSettings } 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 type { TodoStoreEvents } from "./todo-store.js";
|
||||
|
||||
@@ -546,6 +546,7 @@ function AppInner() {
|
||||
const skillsEnabled = experimentalFeatures.skillsView === true;
|
||||
const nodesEnabled = experimentalFeatures.nodesView === true;
|
||||
const researchEnabled = experimentalFeatures.researchView === true;
|
||||
const evalsEnabled = experimentalFeatures.evalsView === true;
|
||||
const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true;
|
||||
const agentsEnabled = true;
|
||||
|
||||
@@ -588,7 +589,10 @@ function AppInner() {
|
||||
if (taskView === "research" && !researchEnabled) {
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -1195,6 +1199,9 @@ function AppInner() {
|
||||
}
|
||||
|
||||
if (taskView === "evals") {
|
||||
if (!settingsLoaded || !evalsEnabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
@@ -1395,6 +1402,7 @@ function AppInner() {
|
||||
devServer: devServerEnabled,
|
||||
devServerView: devServerEnabled,
|
||||
researchView: researchEnabled,
|
||||
evalsView: evalsEnabled,
|
||||
}}
|
||||
pluginDashboardViews={pluginDashboardViews}
|
||||
shellConnectionControl={
|
||||
@@ -1501,6 +1509,7 @@ function AppInner() {
|
||||
devServerView: devServerEnabled,
|
||||
todoView: todosEnabled,
|
||||
researchView: researchEnabled,
|
||||
evalsView: evalsEnabled,
|
||||
nodesView: nodesEnabled,
|
||||
}}
|
||||
pluginDashboardViews={pluginDashboardViews}
|
||||
|
||||
@@ -237,7 +237,7 @@ export interface HeaderProps {
|
||||
/** Whether the current view is a remote node */
|
||||
isRemote?: boolean;
|
||||
/** 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[];
|
||||
shellConnectionControl?: ReactNode;
|
||||
}
|
||||
@@ -1178,7 +1178,7 @@ export function Header({
|
||||
<>
|
||||
<button
|
||||
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)}
|
||||
title="More views"
|
||||
aria-label="More views"
|
||||
@@ -1195,18 +1195,20 @@ export function Header({
|
||||
role="menu"
|
||||
aria-label="More views"
|
||||
>
|
||||
<button
|
||||
className={`view-toggle-overflow-item${view === "evals" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onChangeView("evals");
|
||||
setIsViewOverflowOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
data-testid="view-overflow-evals"
|
||||
>
|
||||
<Target size={14} />
|
||||
<span>Evals</span>
|
||||
</button>
|
||||
{experimentalFeatures?.evalsView && (
|
||||
<button
|
||||
className={`view-toggle-overflow-item${view === "evals" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onChangeView("evals");
|
||||
setIsViewOverflowOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
data-testid="view-overflow-evals"
|
||||
>
|
||||
<Target size={14} />
|
||||
<span>Evals</span>
|
||||
</button>
|
||||
)}
|
||||
{experimentalFeatures?.researchView && (
|
||||
<button
|
||||
className={`view-toggle-overflow-item${view === "research" ? " active" : ""}`}
|
||||
|
||||
@@ -82,6 +82,7 @@ export interface MobileNavBarProps {
|
||||
devServerView?: boolean;
|
||||
todoView?: boolean;
|
||||
researchView?: boolean;
|
||||
evalsView?: boolean;
|
||||
nodesView?: boolean;
|
||||
};
|
||||
onOpenNodes?: () => void;
|
||||
@@ -228,7 +229,7 @@ export function MobileNavBar({
|
||||
|
||||
const isMoreActive =
|
||||
view === "documents"
|
||||
|| view === "evals"
|
||||
|| (Boolean(experimentalFeatures?.evalsView) && view === "evals")
|
||||
|| view === "research"
|
||||
|| view === "insights"
|
||||
|| view === "memory"
|
||||
@@ -610,15 +611,17 @@ export function MobileNavBar({
|
||||
<FileText />
|
||||
<span>Documents</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-more-item"
|
||||
data-testid="mobile-more-item-evals"
|
||||
onClick={() => handleMoreAction(() => onChangeView("evals"))}
|
||||
>
|
||||
<Target />
|
||||
<span>Evals</span>
|
||||
</button>
|
||||
{experimentalFeatures?.evalsView && (
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-more-item"
|
||||
data-testid="mobile-more-item-evals"
|
||||
onClick={() => handleMoreAction(() => onChangeView("evals"))}
|
||||
>
|
||||
<Target />
|
||||
<span>Evals</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showSkillsInMore && (
|
||||
<button
|
||||
|
||||
@@ -275,6 +275,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
|
||||
devServerView: "Dev Server",
|
||||
todoView: "Todo List",
|
||||
researchView: "Research View",
|
||||
evalsView: "Evals View",
|
||||
agentOnboarding: "Planning-style Agent Onboarding",
|
||||
};
|
||||
|
||||
@@ -444,6 +445,7 @@ export function SettingsModal({
|
||||
const experimentalFeatures = form.experimentalFeatures ?? {};
|
||||
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
|
||||
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
|
||||
const evalsViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "evalsView");
|
||||
const visibleSections = SETTINGS_SECTIONS.filter((section) => {
|
||||
if (section.id === "remote") {
|
||||
return remoteAccessEnabled;
|
||||
@@ -453,6 +455,10 @@ export function SettingsModal({
|
||||
return researchViewEnabled;
|
||||
}
|
||||
|
||||
if (section.id === "scheduled-evals") {
|
||||
return evalsViewEnabled;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const firstVisibleSectionId = visibleSections.find((section) => !section.isGroupHeader)?.id ?? "general";
|
||||
@@ -471,10 +477,15 @@ export function SettingsModal({
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSection === "scheduled-evals" && !evalsViewEnabled) {
|
||||
setActiveSection(firstVisibleSectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!visibleSections.some((section) => section.id === activeSection)) {
|
||||
setActiveSection(firstVisibleSectionId);
|
||||
}
|
||||
}, [activeSection, remoteAccessEnabled, researchViewEnabled, firstVisibleSectionId, visibleSections]);
|
||||
}, [activeSection, remoteAccessEnabled, researchViewEnabled, evalsViewEnabled, firstVisibleSectionId, visibleSections]);
|
||||
|
||||
// Auth state (independent of the settings save flow)
|
||||
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
||||
|
||||
@@ -16,7 +16,7 @@ const defaultSettings: Settings = {
|
||||
worktreeInitCommand: "",
|
||||
testCommand: "",
|
||||
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(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 () => {
|
||||
// Set project mode so board view is available
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
@@ -3702,6 +3725,7 @@ describe("App shell connection status plumbing", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host);
|
||||
expect(screen.getByTestId("mobile-nav-tab-more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();
|
||||
|
||||
@@ -274,9 +274,16 @@ describe("Header", () => {
|
||||
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();
|
||||
renderHeader({ onChangeView });
|
||||
renderHeader({ onChangeView, experimentalFeatures: { evalsView: true } });
|
||||
|
||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||
fireEvent.click(screen.getByTestId("view-overflow-evals"));
|
||||
|
||||
@@ -488,9 +488,16 @@ describe("MobileNavBar", () => {
|
||||
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 { 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-more-item-evals"));
|
||||
|
||||
@@ -1887,6 +1887,14 @@ describe("SettingsModal", () => {
|
||||
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 () => {
|
||||
renderModal();
|
||||
|
||||
@@ -2007,6 +2015,43 @@ describe("SettingsModal", () => {
|
||||
expect(screen.queryByRole("button", { name: /Research Defaults/i })).not.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 () => {
|
||||
@@ -2774,6 +2819,7 @@ describe("SettingsModal", () => {
|
||||
it("renders controls and disables interval controls when evals are disabled", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: { evalsView: true },
|
||||
evalSettings: {
|
||||
enabled: false,
|
||||
intervalMs: 86_400_000,
|
||||
@@ -2797,6 +2843,7 @@ describe("SettingsModal", () => {
|
||||
it("saves edited project eval settings payload", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: { evalsView: true },
|
||||
evalSettings: {
|
||||
enabled: true,
|
||||
intervalMs: 86_400_000,
|
||||
@@ -2836,6 +2883,7 @@ describe("SettingsModal", () => {
|
||||
it("clears evaluator provider and model as unset when left blank", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: { evalsView: true },
|
||||
evalSettings: {
|
||||
enabled: true,
|
||||
intervalMs: 86_400_000,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { createRequire } from "node:module";
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
runScheduledEvalBatch,
|
||||
resolveTaskEvaluationSettings,
|
||||
isEvalsExperimentalEnabled,
|
||||
type TaskStore,
|
||||
type AutomationStore,
|
||||
type ScheduledTask,
|
||||
@@ -487,6 +488,15 @@ export class CronRunner {
|
||||
startedAt: string,
|
||||
): Promise<AutomationRunResult> {
|
||||
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 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?.();
|
||||
}
|
||||
})(),
|
||||
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;
|
||||
}
|
||||
@@ -263,7 +263,7 @@ export async function generateFeatureSuggestions(context, count = DEFAULT_SUGGES
|
||||
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;
|
||||
}
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -708,6 +708,22 @@ importers:
|
||||
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-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:
|
||||
dependencies:
|
||||
'@fusion/plugin-sdk':
|
||||
|
||||
@@ -10,3 +10,4 @@ packages:
|
||||
- "plugins/fusion-plugin-agent-browser"
|
||||
- "plugins/fusion-plugin-whatsapp-chat"
|
||||
- "plugins/fusion-plugin-roadmap"
|
||||
- "plugins/fusion-plugin-even-realities-glasses"
|
||||
|
||||
Reference in New Issue
Block a user