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:
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user