feat(FN-3970): consolidate Even plugin APIs into unified package with webho
Collapsed the Even plugin architecture into a single unified plugin by merging board card routes and replacing the transport stub with a real webhook implementation, removing the separate cards plugin from the workspace. The new unified plugin (`fusion-plugin-even-realities-glasses`) now exports con Fusion-Task-Id: FN-3970
This commit is contained in:
@@ -1,52 +1,17 @@
|
||||
# Even Cards Plugin
|
||||
# Even Cards Plugin (Deprecated)
|
||||
|
||||
Standalone Fusion plugin for the Even Realities on-device card flow focused on glanceable board/task status.
|
||||
`fusion-plugin-even-cards` is deprecated and superseded by the canonical plugin:
|
||||
|
||||
## Reading the board
|
||||
- `fusion-plugin-even-realities-glasses`
|
||||
|
||||
All endpoints are mounted under `/api/plugins/fusion-plugin-even-cards`.
|
||||
Board/task card APIs previously provided here (`/board/cards`, `/board`, `/tasks/:id/cards`) now live under the canonical plugin id and route namespace.
|
||||
|
||||
Authentication header (required):
|
||||
## Migration
|
||||
|
||||
```http
|
||||
Authorization: Bearer <apiKey>
|
||||
```
|
||||
Use:
|
||||
|
||||
| Method | Path | Query params | Response |
|
||||
|---|---|---|---|
|
||||
| GET | `/board/cards` | `columns` (comma-separated columns), `max` (1-20 total cards) | `{ deck, generatedAt }` |
|
||||
| GET | `/board` | `columns` (optional) | `{ summary, updatedAt }` |
|
||||
| GET | `/tasks/:id/cards` | none | `{ deck, generatedAt }` or `404` |
|
||||
- `/api/plugins/fusion-plugin-even-realities-glasses/board/cards`
|
||||
- `/api/plugins/fusion-plugin-even-realities-glasses/board`
|
||||
- `/api/plugins/fusion-plugin-even-realities-glasses/tasks/:id/cards`
|
||||
|
||||
`max` is **total deck size** (summary + tasks). Example: `max=20` returns 1 summary card and up to 19 task cards.
|
||||
|
||||
## Card data model
|
||||
|
||||
```ts
|
||||
type GlassesCard = {
|
||||
id: string;
|
||||
kind: "summary" | "task";
|
||||
title: string;
|
||||
lines: string[];
|
||||
badge: { label: string; tone: "triage" | "todo" | "in-progress" | "in-review" | "done" | "neutral" };
|
||||
taskId?: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type CardDeck = {
|
||||
cards: GlassesCard[];
|
||||
summary: {
|
||||
counts: Record<string, number>;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Display defaults
|
||||
|
||||
Current defaults in `src/cards/format.ts`:
|
||||
- `DEFAULT_MAX_CHARS_PER_LINE = 24`
|
||||
- `DEFAULT_MAX_LINES_PER_CARD = 4`
|
||||
- `DEFAULT_MAX_CARDS_PER_DECK = 8`
|
||||
|
||||
These are provisional fallback values because FN-3737 research artifacts were unavailable in this worktree; revisit task is tracked in FN-3754.
|
||||
This package is removed from the active workspace package list and retained only as a temporary compatibility artifact pending full cleanup.
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @fusion-plugin-examples/even-realities-glasses
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Consolidated Even board/task card APIs into the canonical `fusion-plugin-even-realities-glasses` plugin.
|
||||
- Replaced production stub transport with webhook-backed `WebhookGlassesTransport` plus transport status/reconnect/action-ingest routes.
|
||||
- Added companion webhook configuration (`companionWebhookUrl`) and updated route/auth coverage tests.
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -24,7 +24,9 @@ pnpm --filter @fusion-plugin-examples/even-realities-glasses test
|
||||
|
||||
- `fusionApiBaseUrl` (default `http://localhost:4040`)
|
||||
- `fusionApiToken` (required Bearer token)
|
||||
- `apiKey` (required for plugin routes)
|
||||
- `glassesDeviceId` (optional identifier)
|
||||
- `companionWebhookUrl` (optional companion endpoint base URL for card push, e.g. `https://companion.example`)
|
||||
- `pollingIntervalSeconds` (default 30, min 5)
|
||||
- `notifyOnColumns` (default `["in-review"]`)
|
||||
- `quickCaptureDefaultColumn` (default `triage`)
|
||||
@@ -154,8 +156,25 @@ curl -X GET "http://localhost:4040/api/plugins/fusion-plugin-even-realities-glas
|
||||
- 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
|
||||
## Board/task card endpoints (merged canonical surface)
|
||||
|
||||
The plugin intentionally uses `GlassesTransport` + `StubGlassesTransport` for now. The real Even Realities BLE/SDK transport should be wired behind this interface.
|
||||
All companion-facing routes use `Authorization: Bearer <apiKey>` and are hosted under:
|
||||
|
||||
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.
|
||||
`/api/plugins/fusion-plugin-even-realities-glasses`
|
||||
|
||||
| Method | Path | Description |
|
||||
| --- | --- | --- |
|
||||
| GET | `/board/cards` | Card deck projection for selected columns (`columns`, `max`) |
|
||||
| GET | `/board` | Board summary counts and status |
|
||||
| GET | `/tasks/:id/cards` | Single-task card deck |
|
||||
|
||||
## Transport path (implemented)
|
||||
|
||||
Production transport uses `WebhookGlassesTransport`.
|
||||
|
||||
- `pushCard()` POSTs cards to `${companionWebhookUrl}/cards`
|
||||
- `/status` reports `connected`, transport mode, webhook config presence, `lastPushAt`, `lastActionAt`, and `lastError`
|
||||
- `POST /reconnect` forces disconnect/connect on the transport state machine
|
||||
- `POST /transport/actions` ingests companion actions (`start-work`, `request-review`, `quick-capture`) into plugin action handlers
|
||||
|
||||
If `companionWebhookUrl` is unset, the plugin still serves authenticated routes and polling notifications, but card push remains degraded with `connected=false` and a status error until configured.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"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.",
|
||||
"description": "Canonical Even Realities Fusion plugin: board/task cards, quick capture, actions, notifications, and webhook transport.",
|
||||
"author": "Fusion Team",
|
||||
"fusionVersion": ">=0.1.0"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@fusion-plugin-examples/even-realities-glasses",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"description": "Even Realities glasses task card bridge for Fusion",
|
||||
"description": "Canonical Even Realities Fusion plugin with board/task cards, actions, notifications, and webhook transport",
|
||||
"keywords": [
|
||||
"fusion-plugin",
|
||||
"even-realities",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import plugin from "../index.js";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
function makeTask(id: string, column: Task["column"], updatedAt: string): Task {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
description: id,
|
||||
column,
|
||||
status: "pending",
|
||||
priority: "normal",
|
||||
createdAt: "2026-05-08T10:00:00.000Z",
|
||||
updatedAt,
|
||||
currentStep: 0,
|
||||
steps: [],
|
||||
dependencies: [],
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
function createContext(tasks: Task[], apiKey = "secret") {
|
||||
return {
|
||||
pluginId: "fusion-plugin-even-realities-glasses",
|
||||
settings: { apiKey },
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
taskStore: {
|
||||
listTasks: vi.fn(async () => tasks),
|
||||
getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id)),
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function route(path: string) {
|
||||
return plugin.routes!.find((entry) => entry.path === path)!;
|
||||
}
|
||||
|
||||
describe("board routes", () => {
|
||||
it("returns 401 when auth header missing", async () => {
|
||||
const response = (await route("/board/cards").handler({ headers: {} }, createContext([]))) as any;
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 503 when plugin is not configured", async () => {
|
||||
const response = (await route("/board/cards").handler({ headers: { authorization: "Bearer secret" } }, createContext([], ""))) as any;
|
||||
expect(response.status).toBe(503);
|
||||
});
|
||||
|
||||
it("returns deck on happy path", async () => {
|
||||
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z"), makeTask("FN-2", "in-progress", "2026-05-08T12:00:00.000Z")];
|
||||
const response = (await route("/board/cards").handler({ headers: { authorization: "Bearer secret" } }, createContext(tasks))) as any;
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.deck.cards[0].id).toBe("summary");
|
||||
expect(response.body.deck.cards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("returns task deck for known id", async () => {
|
||||
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z")];
|
||||
const response = (await route("/tasks/:id/cards").handler(
|
||||
{ headers: { authorization: "Bearer secret" }, params: { id: "FN-1" } },
|
||||
createContext(tasks),
|
||||
)) as any;
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.deck.cards[0].id).toBe("FN-1");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ describe("even realities plugin", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-even-realities-glasses");
|
||||
expect(Object.keys(plugin.manifest.settingsSchema ?? {}).sort()).toEqual([
|
||||
"apiKey",
|
||||
"companionWebhookUrl",
|
||||
"enableAgentActions",
|
||||
"fusionApiBaseUrl",
|
||||
"fusionApiToken",
|
||||
@@ -41,7 +42,11 @@ describe("even realities plugin", () => {
|
||||
};
|
||||
const ctx = {
|
||||
pluginId: "known",
|
||||
settings: { fusionApiToken: "token", fusionApiBaseUrl: "http://localhost:4040" },
|
||||
settings: {
|
||||
fusionApiToken: "token",
|
||||
fusionApiBaseUrl: "http://localhost:4040",
|
||||
companionWebhookUrl: "https://companion.example",
|
||||
},
|
||||
logger: console,
|
||||
taskStore: { getPluginStore: () => ({ db }) },
|
||||
} as never;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
agentActionsEnabled,
|
||||
getCompanionWebhookUrl,
|
||||
getFusionBaseUrl,
|
||||
getFusionToken,
|
||||
getNotifyColumns,
|
||||
@@ -12,6 +13,7 @@ describe("settings accessors", () => {
|
||||
it("uses safe defaults", () => {
|
||||
expect(getFusionBaseUrl({})).toBe("http://localhost:4040");
|
||||
expect(getFusionToken({})).toBeUndefined();
|
||||
expect(getCompanionWebhookUrl({})).toBeUndefined();
|
||||
expect(getPollingIntervalMs({})).toBe(30000);
|
||||
expect(getNotifyColumns({})).toEqual(["in-review"]);
|
||||
expect(getQuickCaptureColumn({})).toBe("triage");
|
||||
@@ -21,6 +23,9 @@ describe("settings accessors", () => {
|
||||
it("trims string values", () => {
|
||||
expect(getFusionBaseUrl({ fusionApiBaseUrl: " http://fusion.local:4040 " })).toBe("http://fusion.local:4040");
|
||||
expect(getFusionToken({ fusionApiToken: " token " })).toBe("token");
|
||||
expect(getCompanionWebhookUrl({ companionWebhookUrl: " https://companion.example/ingest " })).toBe(
|
||||
"https://companion.example/ingest",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces polling minimum and finite values", () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createTransportRoutes } from "../routes/transport-routes.js";
|
||||
|
||||
describe("transport routes", () => {
|
||||
it("requires api key", async () => {
|
||||
const routes = createTransportRoutes(() => undefined);
|
||||
const route = routes.find((entry) => entry.path === "/transport/actions");
|
||||
const res = await route?.handler({ headers: {} }, { settings: {}, pluginId: "p" } as never);
|
||||
expect(res).toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
it("accepts action payloads", async () => {
|
||||
const receiveAction = vi.fn(async () => undefined);
|
||||
const routes = createTransportRoutes(() => ({ receiveAction } as never));
|
||||
const route = routes.find((entry) => entry.path === "/transport/actions");
|
||||
|
||||
const res = await route?.handler(
|
||||
{
|
||||
headers: { authorization: "Bearer key" },
|
||||
body: { type: "start-work", taskId: "FN-1", timestamp: new Date().toISOString() },
|
||||
},
|
||||
{ settings: { apiKey: "key" }, pluginId: "p" } as never,
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({ status: 202, body: { accepted: true } });
|
||||
expect(receiveAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,35 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StubGlassesTransport } from "../transport.js";
|
||||
import { WebhookGlassesTransport } from "../transport.js";
|
||||
|
||||
describe("StubGlassesTransport", () => {
|
||||
it("records pushes in order", async () => {
|
||||
const transport = new StubGlassesTransport();
|
||||
describe("WebhookGlassesTransport", () => {
|
||||
it("posts cards to companion webhook", async () => {
|
||||
const fetchImpl = vi.fn(async () => ({ ok: true, status: 200 })) as unknown as typeof fetch;
|
||||
const transport = new WebhookGlassesTransport({
|
||||
companionWebhookUrl: "https://companion.example",
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
await transport.connect();
|
||||
await transport.pushCard({ id: "1", kind: "task", title: "A", lines: [], badge: "todo" });
|
||||
await transport.pushCard({ id: "2", kind: "task", title: "B", lines: [], badge: "done" });
|
||||
expect(transport.pushedCards.map((card) => card.id)).toEqual(["1", "2"]);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://companion.example/cards",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(transport.connected).toBe(true);
|
||||
expect(transport.status.lastPushAt).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("emits synthetic actions to handlers", async () => {
|
||||
const transport = new StubGlassesTransport();
|
||||
it("tracks configuration and dispatches actions", async () => {
|
||||
const transport = new WebhookGlassesTransport();
|
||||
const handler = vi.fn();
|
||||
transport.onAction(handler);
|
||||
|
||||
await transport.emitAction({ type: "quick-capture", text: "new task", timestamp: new Date().toISOString() });
|
||||
await transport.connect();
|
||||
await transport.receiveAction?.({ type: "quick-capture", text: "new task", timestamp: new Date().toISOString() });
|
||||
|
||||
expect(transport.connected).toBe(false);
|
||||
expect(transport.status.endpointConfigured).toBe(false);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ type: "quick-capture" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,16 @@ export type GlassesCard = {
|
||||
actions?: GlassesCardAction[];
|
||||
};
|
||||
|
||||
export type BoardSummary = {
|
||||
counts: Record<string, number>;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type CardDeck = {
|
||||
cards: GlassesCard[];
|
||||
summary: BoardSummary;
|
||||
};
|
||||
|
||||
const COLUMN_BADGES: Record<string, string> = {
|
||||
triage: "triage",
|
||||
todo: "todo",
|
||||
@@ -27,18 +37,28 @@ const COLUMN_BADGES: Record<string, string> = {
|
||||
archived: "archived",
|
||||
};
|
||||
|
||||
const COLUMN_ORDER: Array<Task["column"]> = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
|
||||
|
||||
export const DEFAULT_MAX_CHARS_PER_LINE = 24;
|
||||
export const DEFAULT_MAX_LINES_PER_CARD = 4;
|
||||
export const DEFAULT_MAX_CARDS_PER_DECK = 8;
|
||||
const DEFAULT_MAX_TITLE = 80;
|
||||
|
||||
export function truncateLine(value: string, maxChars = DEFAULT_MAX_TITLE): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length <= maxChars) return trimmed;
|
||||
if (maxChars <= 1) return "…";
|
||||
return `${trimmed.slice(0, Math.max(0, maxChars - 1))}…`;
|
||||
}
|
||||
|
||||
export function wrapLines(value: string, opts: { maxCharsPerLine?: number; maxLines?: number } = {}): string[] {
|
||||
export function wrapLines(
|
||||
value: string,
|
||||
opts: { maxCharsPerLine?: number; maxLines?: number } = {},
|
||||
): string[] {
|
||||
const maxCharsPerLine = Math.max(8, opts.maxCharsPerLine ?? 36);
|
||||
const maxLines = Math.max(1, opts.maxLines ?? 2);
|
||||
const words = value.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return [""];
|
||||
if (words.length === 0) return [];
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
for (const word of words) {
|
||||
@@ -53,7 +73,7 @@ export function wrapLines(value: string, opts: { maxCharsPerLine?: number; maxLi
|
||||
}
|
||||
if (lines.length < maxLines && current) lines.push(current);
|
||||
if (lines.length > maxLines) return lines.slice(0, maxLines);
|
||||
if (words.join(" ").length > lines.join(" ").length) {
|
||||
if (words.join(" ").length > lines.join(" ").length && lines.length > 0) {
|
||||
lines[lines.length - 1] = truncateLine(lines[lines.length - 1], maxCharsPerLine);
|
||||
}
|
||||
return lines;
|
||||
@@ -75,16 +95,43 @@ export function formatRelativeAge(updatedAt: string, opts: { now?: () => Date }
|
||||
return `updated ${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function taskToCard(task: Task): GlassesCard {
|
||||
const title = typeof task.title === "string" && task.title.trim() ? task.title : task.description;
|
||||
function boardSummary(tasks: Task[]): BoardSummary {
|
||||
const counts = Object.fromEntries(COLUMN_ORDER.map((column) => [column, 0]));
|
||||
let updatedAt: string | null = null;
|
||||
for (const task of tasks) {
|
||||
counts[task.column] = (counts[task.column] ?? 0) + 1;
|
||||
if (!updatedAt || task.updatedAt > updatedAt) updatedAt = task.updatedAt;
|
||||
}
|
||||
return { counts, updatedAt };
|
||||
}
|
||||
|
||||
function boardSummaryCardFromCounts(summary: BoardSummary, now: string, maxCharsPerLine: number, maxLines: number): GlassesCard {
|
||||
const summaryText = `Triage ${summary.counts.triage} Todo ${summary.counts.todo} Doing ${summary.counts["in-progress"]} Review ${summary.counts["in-review"]} Done ${summary.counts.done}`;
|
||||
return {
|
||||
id: `task-${task.id}`,
|
||||
id: "summary",
|
||||
kind: "summary",
|
||||
title: truncateLine("Fusion board", maxCharsPerLine),
|
||||
lines: wrapLines(summaryText, { maxCharsPerLine, maxLines }),
|
||||
badge: statusBadge("todo"),
|
||||
updatedAt: summary.updatedAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
export function taskToCard(
|
||||
task: Task,
|
||||
opts: { maxCharsPerLine?: number; maxLines?: number; now?: () => Date } = {},
|
||||
): GlassesCard {
|
||||
const title = typeof task.title === "string" && task.title.trim() ? task.title : task.description;
|
||||
const maxCharsPerLine = opts.maxCharsPerLine ?? DEFAULT_MAX_CHARS_PER_LINE;
|
||||
const maxLines = opts.maxLines ?? DEFAULT_MAX_LINES_PER_CARD;
|
||||
return {
|
||||
id: task.id,
|
||||
kind: "task",
|
||||
title: truncateLine(title, DEFAULT_MAX_TITLE),
|
||||
lines: [
|
||||
`assignee: ${task.assignedAgentId ?? task.assigneeUserId ?? "unassigned"}`,
|
||||
formatRelativeAge(task.updatedAt),
|
||||
],
|
||||
title: truncateLine(`${task.id.toUpperCase()} ${title}`, maxCharsPerLine),
|
||||
lines: wrapLines(
|
||||
`Priority ${task.priority ?? "normal"} Assignee ${task.assignedAgentId ?? task.assigneeUserId ?? "unassigned"} Age ${formatRelativeAge(task.createdAt ?? task.updatedAt, { now: opts.now })}`,
|
||||
{ maxCharsPerLine, maxLines },
|
||||
),
|
||||
badge: statusBadge(task.column),
|
||||
taskId: task.id,
|
||||
updatedAt: task.updatedAt,
|
||||
@@ -95,6 +142,29 @@ export function taskToCard(task: Task): GlassesCard {
|
||||
};
|
||||
}
|
||||
|
||||
export function boardToDeck(
|
||||
tasks: Task[],
|
||||
opts: { maxCharsPerLine?: number; maxLines?: number; maxCards?: number; now?: string } = {},
|
||||
): CardDeck {
|
||||
const maxCharsPerLine = opts.maxCharsPerLine ?? DEFAULT_MAX_CHARS_PER_LINE;
|
||||
const maxLines = opts.maxLines ?? DEFAULT_MAX_LINES_PER_CARD;
|
||||
const maxCards = opts.maxCards ?? DEFAULT_MAX_CARDS_PER_DECK;
|
||||
const now = opts.now ?? new Date().toISOString();
|
||||
const summary = boardSummary(tasks);
|
||||
const active = tasks
|
||||
.filter((task) => task.column !== "archived" && task.column !== "done")
|
||||
.sort((a, b) => (b.updatedAt === a.updatedAt ? b.id.localeCompare(a.id) : b.updatedAt.localeCompare(a.updatedAt)))
|
||||
.slice(0, Math.max(0, maxCards - 1));
|
||||
|
||||
return {
|
||||
cards: [
|
||||
boardSummaryCardFromCounts(summary, now, maxCharsPerLine, maxLines),
|
||||
...active.map((task) => taskToCard(task, { maxCharsPerLine, maxLines, now: () => new Date(now) })),
|
||||
],
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
export function boardSummaryCard(tasksByColumn: Record<string, number>): GlassesCard {
|
||||
const ordered = ["triage", "todo", "in-progress", "in-review", "done"];
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import type { FusionPlugin, PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
|
||||
import { FusionApiClient } from "./fusion-api-client.js";
|
||||
import { createNotifier } from "./notifier.js";
|
||||
import { requestReview, startWork } from "./agent-actions.js";
|
||||
import { runQuickCapture } from "./quick-capture.js";
|
||||
import { quickCaptureRoutes } from "./routes/quick-capture-routes.js";
|
||||
import { createNotificationRoutes } from "./routes/notification-routes.js";
|
||||
import { agentActionRoutes } from "./routes/agent-action-routes.js";
|
||||
import { getFusionBaseUrl, getFusionToken, getNotifyColumns, settingsSchema } from "./settings.js";
|
||||
import { StubGlassesTransport } from "./transport.js";
|
||||
import { boardRoutes } from "./routes/board-routes.js";
|
||||
import {
|
||||
getCompanionWebhookUrl,
|
||||
getFusionToken,
|
||||
getNotifyColumns,
|
||||
getQuickCaptureColumn,
|
||||
settingsSchema,
|
||||
} from "./settings.js";
|
||||
import { WebhookGlassesTransport } from "./transport.js";
|
||||
import { createTransportRoutes } from "./routes/transport-routes.js";
|
||||
|
||||
export type PluginDb = {
|
||||
exec(sql: string): void;
|
||||
@@ -18,12 +27,9 @@ export type PluginDb = {
|
||||
};
|
||||
|
||||
type PluginInstance = {
|
||||
client: FusionApiClient;
|
||||
transport: StubGlassesTransport;
|
||||
transport: WebhookGlassesTransport;
|
||||
notifier: ReturnType<typeof createNotifier>;
|
||||
};
|
||||
|
||||
const instances = new Map<string, PluginInstance>();
|
||||
};const instances = new Map<string, PluginInstance>();
|
||||
|
||||
function getDbFromTaskStore(ctx: PluginContext): PluginDb {
|
||||
const pluginStore = ctx.taskStore.getPluginStore();
|
||||
@@ -49,6 +55,7 @@ const coreRoutes: PluginRouteDefinition[] = [
|
||||
status: 200,
|
||||
body: {
|
||||
connected: instance.transport.connected,
|
||||
transport: instance.transport.status,
|
||||
lastPollTime: instance.notifier.lastPolledAt() ?? null,
|
||||
notifyOnColumns: getNotifyColumns(ctx.settings),
|
||||
},
|
||||
@@ -69,6 +76,7 @@ const coreRoutes: PluginRouteDefinition[] = [
|
||||
];
|
||||
|
||||
const notificationRoutes = createNotificationRoutes((ctx) => instances.get(ctx.pluginId)?.notifier);
|
||||
const transportRoutes = createTransportRoutes((ctx) => instances.get(ctx.pluginId)?.transport);
|
||||
|
||||
const plugin: FusionPlugin = definePlugin({
|
||||
manifest: {
|
||||
@@ -81,7 +89,7 @@ const plugin: FusionPlugin = definePlugin({
|
||||
settingsSchema,
|
||||
},
|
||||
state: "installed",
|
||||
routes: [...coreRoutes, ...quickCaptureRoutes, ...agentActionRoutes, ...notificationRoutes],
|
||||
routes: [...coreRoutes, ...boardRoutes, ...quickCaptureRoutes, ...agentActionRoutes, ...notificationRoutes, ...transportRoutes],
|
||||
hooks: {
|
||||
onSchemaInit: (db) => {
|
||||
(db as PluginDb).exec(`
|
||||
@@ -99,9 +107,35 @@ const plugin: FusionPlugin = definePlugin({
|
||||
return;
|
||||
}
|
||||
const db = getDbFromTaskStore(ctx);
|
||||
const client = new FusionApiClient(getFusionBaseUrl(ctx.settings), token);
|
||||
const transport = new StubGlassesTransport();
|
||||
const transport = new WebhookGlassesTransport({
|
||||
companionWebhookUrl: getCompanionWebhookUrl(ctx.settings),
|
||||
});
|
||||
await transport.connect();
|
||||
transport.onAction(async (action) => {
|
||||
if (action.type === "quick-capture") {
|
||||
await runQuickCapture(
|
||||
{ text: action.text, column: undefined },
|
||||
{
|
||||
taskStore: ctx.taskStore,
|
||||
pluginId: ctx.pluginId,
|
||||
defaultColumn: getQuickCaptureColumn(ctx.settings),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!action.taskId) return;
|
||||
|
||||
if (action.type === "start-work") {
|
||||
await startWork({ taskId: action.taskId }, { taskStore: ctx.taskStore, pluginId: ctx.pluginId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.type === "request-review") {
|
||||
await requestReview({ taskId: action.taskId }, { taskStore: ctx.taskStore, pluginId: ctx.pluginId });
|
||||
}
|
||||
});
|
||||
|
||||
const notifier = createNotifier({
|
||||
taskStore: ctx.taskStore,
|
||||
db,
|
||||
@@ -111,7 +145,7 @@ const plugin: FusionPlugin = definePlugin({
|
||||
pluginId: ctx.pluginId,
|
||||
});
|
||||
notifier.start();
|
||||
instances.set(ctx.pluginId, { client, transport, notifier });
|
||||
instances.set(ctx.pluginId, { transport, notifier });
|
||||
},
|
||||
onUnload: async () => {
|
||||
for (const [pluginId, instance] of instances.entries()) {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Task } from "@fusion/core";
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
|
||||
import {
|
||||
boardToDeck,
|
||||
DEFAULT_MAX_CARDS_PER_DECK,
|
||||
DEFAULT_MAX_CHARS_PER_LINE,
|
||||
DEFAULT_MAX_LINES_PER_CARD,
|
||||
taskToCard,
|
||||
} from "../cards.js";
|
||||
import { requireApiKey } from "./quick-capture-routes.js";
|
||||
|
||||
const ALLOWED_COLUMNS: Array<Task["column"]> = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
|
||||
|
||||
function parseColumns(raw: unknown): Set<Task["column"]> | null {
|
||||
if (typeof raw !== "string" || !raw.trim()) return null;
|
||||
const parsed = raw
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value): value is Task["column"] => ALLOWED_COLUMNS.includes(value as Task["column"]));
|
||||
return parsed.length ? new Set(parsed) : null;
|
||||
}
|
||||
|
||||
function parseMax(raw: unknown): number {
|
||||
const value = typeof raw === "string" ? Number.parseInt(raw, 10) : Number.NaN;
|
||||
if (!Number.isFinite(value)) return DEFAULT_MAX_CARDS_PER_DECK;
|
||||
return Math.max(1, Math.min(20, Math.floor(value)));
|
||||
}
|
||||
|
||||
function requestData(req: unknown): {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
query: Record<string, unknown>;
|
||||
params: Record<string, unknown>;
|
||||
} {
|
||||
const candidate = (req ?? {}) as {
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
query?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
return { headers: candidate.headers ?? {}, query: candidate.query ?? {}, params: candidate.params ?? {} };
|
||||
}
|
||||
|
||||
async function getBoardCards(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
|
||||
const request = requestData(req);
|
||||
const auth = requireApiKey(ctx, { headers: request.headers });
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const all = ((await ctx.taskStore.listTasks({ includeArchived: false })) as Task[]) ?? [];
|
||||
const columns = parseColumns(request.query.columns);
|
||||
const filtered = columns ? all.filter((task) => columns.has(task.column)) : all;
|
||||
const maxCards = parseMax(request.query.max);
|
||||
const deck = boardToDeck(filtered, {
|
||||
maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE,
|
||||
maxLines: DEFAULT_MAX_LINES_PER_CARD,
|
||||
maxCards,
|
||||
});
|
||||
return { status: 200, body: { deck, generatedAt: new Date().toISOString() } };
|
||||
}
|
||||
|
||||
async function getBoardSummary(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
|
||||
const request = requestData(req);
|
||||
const auth = requireApiKey(ctx, { headers: request.headers });
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const all = ((await ctx.taskStore.listTasks({ includeArchived: false })) as Task[]) ?? [];
|
||||
const columns = parseColumns(request.query.columns);
|
||||
const filtered = columns ? all.filter((task) => columns.has(task.column)) : all;
|
||||
const deck = boardToDeck(filtered, {
|
||||
maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE,
|
||||
maxLines: DEFAULT_MAX_LINES_PER_CARD,
|
||||
maxCards: 1,
|
||||
});
|
||||
|
||||
return { status: 200, body: { summary: deck.summary, updatedAt: deck.summary.updatedAt } };
|
||||
}
|
||||
|
||||
async function getTaskCards(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
|
||||
const request = requestData(req);
|
||||
const auth = requireApiKey(ctx, { headers: request.headers });
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const taskId = typeof request.params.id === "string" ? request.params.id.trim() : "";
|
||||
if (!taskId) return { status: 400, body: { error: "task id is required" } };
|
||||
|
||||
const task = (await ctx.taskStore.getTask(taskId)) as Task | undefined;
|
||||
if (!task) return { status: 404, body: { error: "task not found" } };
|
||||
|
||||
const deck = {
|
||||
cards: [taskToCard(task, { maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE, maxLines: DEFAULT_MAX_LINES_PER_CARD })],
|
||||
summary: boardToDeck([task], { maxCards: 1 }).summary,
|
||||
};
|
||||
|
||||
return { status: 200, body: { deck, generatedAt: new Date().toISOString() } };
|
||||
}
|
||||
|
||||
export const boardRoutes: PluginRouteDefinition[] = [
|
||||
{ method: "GET", path: "/board/cards", handler: getBoardCards, description: "Read-only board card deck" },
|
||||
{ method: "GET", path: "/board", handler: getBoardSummary, description: "Read-only board summary" },
|
||||
{ method: "GET", path: "/tasks/:id/cards", handler: getTaskCards, description: "Read-only single task card deck" },
|
||||
];
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { PluginContext, PluginRouteDefinition } from "@fusion/plugin-sdk";
|
||||
import type { GlassesAction, WebhookGlassesTransport } from "../transport.js";
|
||||
import { requireApiKey } from "./quick-capture-routes.js";
|
||||
|
||||
function parseAction(body: unknown): GlassesAction | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const input = body as Record<string, unknown>;
|
||||
if (input.type !== "start-work" && input.type !== "request-review" && input.type !== "quick-capture") {
|
||||
return null;
|
||||
}
|
||||
if (typeof input.timestamp !== "string" || input.timestamp.trim().length === 0) return null;
|
||||
|
||||
return {
|
||||
type: input.type,
|
||||
taskId: typeof input.taskId === "string" ? input.taskId : undefined,
|
||||
text: typeof input.text === "string" ? input.text : undefined,
|
||||
timestamp: input.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTransportRoutes(
|
||||
getTransport: (ctx: PluginContext) => WebhookGlassesTransport | undefined,
|
||||
): PluginRouteDefinition[] {
|
||||
return [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/transport/actions",
|
||||
handler: async (req, ctx) => {
|
||||
const auth = requireApiKey(ctx, req as { headers?: Record<string, string | string[] | undefined> });
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const transport = getTransport(ctx);
|
||||
if (!transport) return { status: 503, body: { error: "transport not running" } };
|
||||
|
||||
const action = parseAction((req as { body?: unknown }).body);
|
||||
if (!action) return { status: 400, body: { error: "invalid action payload" } };
|
||||
|
||||
await transport.receiveAction(action);
|
||||
return { status: 202, body: { accepted: true } };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -28,6 +28,10 @@ export const settingsSchema: Record<string, PluginSettingSchema> = {
|
||||
type: "string",
|
||||
label: "Glasses Device ID",
|
||||
},
|
||||
companionWebhookUrl: {
|
||||
type: "string",
|
||||
label: "Companion Webhook URL",
|
||||
},
|
||||
pollingIntervalSeconds: {
|
||||
type: "number",
|
||||
label: "Polling Interval (seconds)",
|
||||
@@ -65,6 +69,10 @@ export function getFusionToken(settings: Record<string, unknown>): string | unde
|
||||
return getSettingString(settings, "fusionApiToken");
|
||||
}
|
||||
|
||||
export function getCompanionWebhookUrl(settings: Record<string, unknown>): string | undefined {
|
||||
return getSettingString(settings, "companionWebhookUrl");
|
||||
}
|
||||
|
||||
export function getPollingIntervalMs(settings: Record<string, unknown>): number {
|
||||
const raw = settings.pollingIntervalSeconds;
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
|
||||
@@ -12,30 +12,99 @@ export interface GlassesTransport {
|
||||
disconnect(): Promise<void>;
|
||||
pushCard(card: GlassesCard): Promise<void>;
|
||||
onAction(handler: (action: GlassesAction) => void | Promise<void>): void;
|
||||
receiveAction?(action: GlassesAction): Promise<void>;
|
||||
readonly connected?: boolean;
|
||||
readonly status?: {
|
||||
mode: "webhook";
|
||||
endpointConfigured: boolean;
|
||||
lastPushAt: string | null;
|
||||
lastActionAt: string | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export class StubGlassesTransport implements GlassesTransport {
|
||||
type WebhookTransportOptions = {
|
||||
companionWebhookUrl?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
};
|
||||
|
||||
function normalizeWebhookUrl(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export class WebhookGlassesTransport implements GlassesTransport {
|
||||
private handlers: Array<(action: GlassesAction) => void | Promise<void>> = [];
|
||||
public readonly pushedCards: GlassesCard[] = [];
|
||||
public connected = false;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly companionWebhookUrl?: string;
|
||||
private _connected = false;
|
||||
private _lastPushAt: string | null = null;
|
||||
private _lastActionAt: string | null = null;
|
||||
private _lastError: string | null = null;
|
||||
|
||||
constructor(options: WebhookTransportOptions = {}) {
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.companionWebhookUrl = normalizeWebhookUrl(options.companionWebhookUrl);
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this._connected && Boolean(this.companionWebhookUrl);
|
||||
}
|
||||
|
||||
get status() {
|
||||
return {
|
||||
mode: "webhook" as const,
|
||||
endpointConfigured: Boolean(this.companionWebhookUrl),
|
||||
lastPushAt: this._lastPushAt,
|
||||
lastActionAt: this._lastActionAt,
|
||||
lastError: this._lastError,
|
||||
};
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.connected = true;
|
||||
this._connected = true;
|
||||
this._lastError = this.companionWebhookUrl ? null : "companionWebhookUrl not configured";
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.connected = false;
|
||||
this._connected = false;
|
||||
}
|
||||
|
||||
async pushCard(card: GlassesCard): Promise<void> {
|
||||
this.pushedCards.push(card);
|
||||
if (!this.companionWebhookUrl) {
|
||||
this._lastError = "companionWebhookUrl not configured";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.fetchImpl(`${this.companionWebhookUrl}/cards`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ card }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
this._lastError = `companion webhook responded ${response.status}`;
|
||||
this._connected = false;
|
||||
throw new Error(this._lastError);
|
||||
}
|
||||
this._connected = true;
|
||||
this._lastPushAt = new Date().toISOString();
|
||||
this._lastError = null;
|
||||
} catch (error) {
|
||||
this._connected = false;
|
||||
this._lastError = error instanceof Error ? error.message : String(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
onAction(handler: (action: GlassesAction) => void | Promise<void>): void {
|
||||
this.handlers.push(handler);
|
||||
}
|
||||
|
||||
async emitAction(action: GlassesAction): Promise<void> {
|
||||
async receiveAction(action: GlassesAction): Promise<void> {
|
||||
this._lastActionAt = new Date().toISOString();
|
||||
for (const handler of this.handlers) {
|
||||
await handler(action);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user