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:
5
.changeset/fn-3970-removal.md
Normal file
5
.changeset/fn-3970-removal.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Consolidate Even Realities plugin support into `fusion-plugin-even-realities-glasses` and remove `fusion-plugin-even-cards` from the active workspace package list to avoid duplicate user-facing integrations.
|
||||||
@@ -1205,21 +1205,16 @@ Example plugin demonstrating settings schema and runtime configuration with all
|
|||||||
- Features: Configurable greeting message, tag limit, logging toggle, log level selector
|
- Features: Configurable greeting message, tag limit, logging toggle, log level selector
|
||||||
- **Install from Settings**: Designed to be installed via the dashboard Settings → Plugins UI
|
- **Install from Settings**: Designed to be installed via the dashboard Settings → Plugins UI
|
||||||
|
|
||||||
### [Even Cards Plugin](../../plugins/fusion-plugin-even-cards/)
|
|
||||||
|
|
||||||
Read-only board/task card projection plugin for Even Realities companion flows.
|
|
||||||
|
|
||||||
- Demonstrates: plugin routes protected with API-key auth, store reads via `ctx.taskStore`, and card-deck projection helpers
|
|
||||||
- Features: `GET /board/cards`, `GET /board`, and `GET /tasks/:id/cards` endpoints with compact card payloads
|
|
||||||
|
|
||||||
### [Even Realities Glasses Plugin](../../plugins/fusion-plugin-even-realities-glasses/)
|
### [Even Realities Glasses Plugin](../../plugins/fusion-plugin-even-realities-glasses/)
|
||||||
|
|
||||||
Task-focused card bridge plugin for Even Realities glasses companion flows.
|
Task-focused card bridge plugin for Even Realities glasses companion flows.
|
||||||
|
|
||||||
- Features: quick capture text into new tasks via the plugin route
|
- Features: quick capture text into new tasks via the plugin route
|
||||||
- Features: polling-based task transition notifications on configured columns (default `in-review`)
|
- Features: polling-based task transition notifications on configured columns (default `in-review`)
|
||||||
|
- Features: board/task card endpoints (`GET /board/cards`, `GET /board`, `GET /tasks/:id/cards`)
|
||||||
- Features: agent actions for start work (`in-progress`) and request review (`in-review`), gated by `enableAgentActions`
|
- Features: agent actions for start work (`in-progress`) and request review (`in-review`), gated by `enableAgentActions`
|
||||||
- Demonstrates: settings schema for `fusionApiBaseUrl`, `fusionApiToken`, `glassesDeviceId`, `pollingIntervalSeconds`, `notifyOnColumns`, `quickCaptureDefaultColumn`, and `enableAgentActions`
|
- Features: webhook transport bridge with companion action ingestion (`POST /transport/actions`) and reconnect/status endpoints
|
||||||
|
- Demonstrates: settings schema for `fusionApiBaseUrl`, `fusionApiToken`, `apiKey`, `glassesDeviceId`, `companionWebhookUrl`, `pollingIntervalSeconds`, `notifyOnColumns`, `quickCaptureDefaultColumn`, and `enableAgentActions`
|
||||||
- Demonstrates FN-3737-aligned display limits: `EVEN_CARD_MAX_CHARS_PER_LINE = 28`, `EVEN_CARD_MAX_LINES_PER_CARD = 8`, `EVEN_CARD_MAX_DECK_SIZE = 12`
|
- Demonstrates FN-3737-aligned display limits: `EVEN_CARD_MAX_CHARS_PER_LINE = 28`, `EVEN_CARD_MAX_LINES_PER_CARD = 8`, `EVEN_CARD_MAX_DECK_SIZE = 12`
|
||||||
|
|
||||||
### Installing Example Plugins from Settings
|
### Installing Example Plugins from Settings
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
## 1. Summary & Recommendation
|
## 1. Summary & Recommendation
|
||||||
Recommend **Option A** for v1: a **Fusion plugin + Even companion app bridge**. The companion app (or Even Hub-hosted webview app) handles device/app bridge APIs and forwards minimal HTTP calls to Fusion (`fn serve`/dashboard API). This aligns with currently visible Even SDK positioning (WebView ↔ Even App bridge, not direct glasses→LAN HTTP) and lets Fusion reuse existing task/agent REST APIs and plugin route hosting.
|
Recommend **Option A** for v1: a **Fusion plugin + Even companion app bridge**. The companion app (or Even Hub-hosted webview app) handles device/app bridge APIs and forwards minimal HTTP calls to Fusion (`fn serve`/dashboard API). This aligns with currently visible Even SDK positioning (WebView ↔ Even App bridge, not direct glasses→LAN HTTP) and lets Fusion reuse existing task/agent REST APIs and plugin route hosting.
|
||||||
|
|
||||||
|
### Implementation status (FN-3970)
|
||||||
|
- Implemented in Fusion plugin: canonical `fusion-plugin-even-realities-glasses` route surface now includes board/task card endpoints, quick capture, actions, notifications, and transport status/reconnect routes.
|
||||||
|
- Production transport path now uses webhook-based `WebhookGlassesTransport` (`companionWebhookUrl`) instead of a stub transport.
|
||||||
|
- Companion app delivery remains out of scope for this document/task chain; this doc still defines the recommended topology and open ecosystem questions.
|
||||||
|
|
||||||
## 2. Even Realities Platform Overview
|
## 2. Even Realities Platform Overview
|
||||||
|
|
||||||
### 2.1 Developer surface (SDKs, docs, supported hosts)
|
### 2.1 Developer surface (SDKs, docs, supported hosts)
|
||||||
|
|||||||
@@ -1,17 +1,66 @@
|
|||||||
# Even Realities Plugin API
|
# Even Realities Plugin API
|
||||||
|
|
||||||
## Test Coverage
|
Canonical plugin id: `fusion-plugin-even-realities-glasses`
|
||||||
|
|
||||||
| Endpoint | Contract Row | Test Suite | Assertion Type |
|
All plugin routes are mounted under:
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `GET /api/plugins/fusion-plugin-even-realities-glasses/board` | Auth required when daemon token enabled | `auth.test.ts` | `it.each` matrix for valid bearer / missing / wrong / `?fn_token=` fallback |
|
`/api/plugins/fusion-plugin-even-realities-glasses`
|
||||||
| `GET /api/plugins/fusion-plugin-even-realities-glasses/board` | Board response includes expected columns/task summaries | `integration.test.ts` | Integration response contract assertion |
|
|
||||||
| `GET /api/plugins/fusion-plugin-even-realities-glasses/tasks/:id` | Optional `summary` capped at 200 chars | `integration.test.ts` | Integration field-boundary assertion |
|
All companion-facing routes require:
|
||||||
| `GET /api/plugins/fusion-plugin-even-realities-glasses/changes` | Polling contract: strict `updatedAt > since`, ASC ordering, default/capped limits, `hasMore`, monotonic `serverTime` | `polling.test.ts` | Cursor + pagination contract assertions |
|
|
||||||
| `GET /api/plugins/fusion-plugin-even-realities-glasses/changes` | Empty diff response shape `{ changes, hasMore, serverTime }` | `polling.test.ts` | Empty-state response assertion |
|
`Authorization: Bearer <apiKey>`
|
||||||
| `POST /api/plugins/fusion-plugin-even-realities-glasses/quick-capture` | Input validation (required title, title cap) and default column behavior | `quick-capture.test.ts` | Validation + creation side-effect assertions |
|
|
||||||
| `POST /api/plugins/fusion-plugin-even-realities-glasses/quick-capture` | Task-summary-only response (no description/prompt/log leakage), payload bounded | `response-shape.test.ts`, `quick-capture.test.ts` | Whitelist + payload-size assertions |
|
## Endpoint Contract
|
||||||
| `POST /api/plugins/fusion-plugin-even-realities-glasses/actions/start-work` | Action enabled/disabled by `enableAgentActions` | `agent-actions.test.ts` | Feature-gate integration assertions |
|
|
||||||
| `POST /api/plugins/fusion-plugin-even-realities-glasses/actions/request-review` | Action enabled/disabled by `enableAgentActions` | `agent-actions.test.ts` | Feature-gate integration assertions |
|
### Board / Task Cards
|
||||||
| `POST /api/plugins/fusion-plugin-even-realities-glasses/actions/*` | Unknown task id rejected | `agent-actions.test.ts` | 404 contract assertion |
|
|
||||||
| `/api/plugins/fusion-plugin-even-realities-glasses/<plugin-route>` and `/api/plugins/fusion-plugin-even-realities-glasses/enable` | Plugin route mount + management-route collision safety | `packages/dashboard/src/__tests__/plugin-routes-wiring.test.ts` | Routing/wiring integration assertions |
|
- `GET /board/cards` → compact deck for selected columns (`columns`, `max`)
|
||||||
|
- `GET /board` → board summary counts and updated timestamp
|
||||||
|
- `GET /tasks/:id/cards` → single-task deck (`404` when missing)
|
||||||
|
|
||||||
|
### Quick Capture
|
||||||
|
|
||||||
|
- `POST /quick-capture`
|
||||||
|
- Body: `{ "text": string, "column"?: TaskColumn }`
|
||||||
|
- Creates a task using normalization + configured `quickCaptureDefaultColumn`
|
||||||
|
|
||||||
|
### Agent Actions
|
||||||
|
|
||||||
|
- `POST /actions/start-work`
|
||||||
|
- `POST /actions/request-review`
|
||||||
|
- `POST /actions/approve-plan`
|
||||||
|
- `POST /actions/accept-review`
|
||||||
|
- `POST /actions/return-to-agent`
|
||||||
|
- `POST /actions/retry`
|
||||||
|
|
||||||
|
Body: `{ "taskId": "FN-123" }`
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
- `GET /notifications` (`limit`, optional `drain=true`)
|
||||||
|
- `POST /notifications/ack` with `{ "taskIds": string[] }`
|
||||||
|
- `POST /notifications/poll-now`
|
||||||
|
|
||||||
|
### Transport Status / Control
|
||||||
|
|
||||||
|
- `GET /status` → `connected`, transport mode/config/error, last poll
|
||||||
|
- `POST /reconnect` → reconnect transport lifecycle
|
||||||
|
- `POST /transport/actions` → ingest companion actions (`start-work`, `request-review`, `quick-capture`)
|
||||||
|
|
||||||
|
## Transport Contract
|
||||||
|
|
||||||
|
Production transport is `WebhookGlassesTransport`:
|
||||||
|
|
||||||
|
- pushes cards to `${companionWebhookUrl}/cards`
|
||||||
|
- reports degraded status when webhook URL is missing/unreachable
|
||||||
|
- records `lastPushAt`, `lastActionAt`, and `lastError` for `/status`
|
||||||
|
|
||||||
|
## Test Coverage Map
|
||||||
|
|
||||||
|
- `src/__tests__/board-routes.test.ts` — auth + board/task card route contracts
|
||||||
|
- `src/__tests__/cards.test.ts` — card/deck projection budgets and formatting
|
||||||
|
- `src/__tests__/quick-capture-routes.test.ts` — quick capture auth/input/error behavior
|
||||||
|
- `src/__tests__/agent-action-routes.test.ts` + `agent-actions.test.ts` — action route contract and state transitions
|
||||||
|
- `src/__tests__/notification-routes.test.ts` + `notifier.test.ts` — polling queue, ack, forced poll behavior
|
||||||
|
- `src/__tests__/transport.test.ts` + `transport-routes.test.ts` — webhook push path, connection status, inbound action ingestion
|
||||||
|
- `src/__tests__/index.test.ts` — manifest/settings keys and plugin lifecycle initialization behavior
|
||||||
|
|||||||
@@ -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
|
Use:
|
||||||
Authorization: Bearer <apiKey>
|
|
||||||
```
|
|
||||||
|
|
||||||
| Method | Path | Query params | Response |
|
- `/api/plugins/fusion-plugin-even-realities-glasses/board/cards`
|
||||||
|---|---|---|---|
|
- `/api/plugins/fusion-plugin-even-realities-glasses/board`
|
||||||
| GET | `/board/cards` | `columns` (comma-separated columns), `max` (1-20 total cards) | `{ deck, generatedAt }` |
|
- `/api/plugins/fusion-plugin-even-realities-glasses/tasks/:id/cards`
|
||||||
| GET | `/board` | `columns` (optional) | `{ summary, updatedAt }` |
|
|
||||||
| GET | `/tasks/:id/cards` | none | `{ deck, generatedAt }` or `404` |
|
|
||||||
|
|
||||||
`max` is **total deck size** (summary + tasks). Example: `max=20` returns 1 summary card and up to 19 task cards.
|
This package is removed from the active workspace package list and retained only as a temporary compatibility artifact pending full cleanup.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# @fusion-plugin-examples/even-realities-glasses
|
# @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
|
## 0.1.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ pnpm --filter @fusion-plugin-examples/even-realities-glasses test
|
|||||||
|
|
||||||
- `fusionApiBaseUrl` (default `http://localhost:4040`)
|
- `fusionApiBaseUrl` (default `http://localhost:4040`)
|
||||||
- `fusionApiToken` (required Bearer token)
|
- `fusionApiToken` (required Bearer token)
|
||||||
|
- `apiKey` (required for plugin routes)
|
||||||
- `glassesDeviceId` (optional identifier)
|
- `glassesDeviceId` (optional identifier)
|
||||||
|
- `companionWebhookUrl` (optional companion endpoint base URL for card push, e.g. `https://companion.example`)
|
||||||
- `pollingIntervalSeconds` (default 30, min 5)
|
- `pollingIntervalSeconds` (default 30, min 5)
|
||||||
- `notifyOnColumns` (default `["in-review"]`)
|
- `notifyOnColumns` (default `["in-review"]`)
|
||||||
- `quickCaptureDefaultColumn` (default `triage`)
|
- `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.
|
- Prefer local/self-hosted Fusion instances and avoid exposing dashboard APIs to public networks.
|
||||||
- Treat `fusionApiToken` as secret material and rotate regularly.
|
- 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",
|
"id": "fusion-plugin-even-realities-glasses",
|
||||||
"name": "Even Realities Glasses",
|
"name": "Even Realities Glasses",
|
||||||
"version": "0.1.0",
|
"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",
|
"author": "Fusion Team",
|
||||||
"fusionVersion": ">=0.1.0"
|
"fusionVersion": ">=0.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@fusion-plugin-examples/even-realities-glasses",
|
"name": "@fusion-plugin-examples/even-realities-glasses",
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"type": "module",
|
"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": [
|
"keywords": [
|
||||||
"fusion-plugin",
|
"fusion-plugin",
|
||||||
"even-realities",
|
"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(plugin.manifest.id).toBe("fusion-plugin-even-realities-glasses");
|
||||||
expect(Object.keys(plugin.manifest.settingsSchema ?? {}).sort()).toEqual([
|
expect(Object.keys(plugin.manifest.settingsSchema ?? {}).sort()).toEqual([
|
||||||
"apiKey",
|
"apiKey",
|
||||||
|
"companionWebhookUrl",
|
||||||
"enableAgentActions",
|
"enableAgentActions",
|
||||||
"fusionApiBaseUrl",
|
"fusionApiBaseUrl",
|
||||||
"fusionApiToken",
|
"fusionApiToken",
|
||||||
@@ -41,7 +42,11 @@ describe("even realities plugin", () => {
|
|||||||
};
|
};
|
||||||
const ctx = {
|
const ctx = {
|
||||||
pluginId: "known",
|
pluginId: "known",
|
||||||
settings: { fusionApiToken: "token", fusionApiBaseUrl: "http://localhost:4040" },
|
settings: {
|
||||||
|
fusionApiToken: "token",
|
||||||
|
fusionApiBaseUrl: "http://localhost:4040",
|
||||||
|
companionWebhookUrl: "https://companion.example",
|
||||||
|
},
|
||||||
logger: console,
|
logger: console,
|
||||||
taskStore: { getPluginStore: () => ({ db }) },
|
taskStore: { getPluginStore: () => ({ db }) },
|
||||||
} as never;
|
} as never;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
agentActionsEnabled,
|
agentActionsEnabled,
|
||||||
|
getCompanionWebhookUrl,
|
||||||
getFusionBaseUrl,
|
getFusionBaseUrl,
|
||||||
getFusionToken,
|
getFusionToken,
|
||||||
getNotifyColumns,
|
getNotifyColumns,
|
||||||
@@ -12,6 +13,7 @@ describe("settings accessors", () => {
|
|||||||
it("uses safe defaults", () => {
|
it("uses safe defaults", () => {
|
||||||
expect(getFusionBaseUrl({})).toBe("http://localhost:4040");
|
expect(getFusionBaseUrl({})).toBe("http://localhost:4040");
|
||||||
expect(getFusionToken({})).toBeUndefined();
|
expect(getFusionToken({})).toBeUndefined();
|
||||||
|
expect(getCompanionWebhookUrl({})).toBeUndefined();
|
||||||
expect(getPollingIntervalMs({})).toBe(30000);
|
expect(getPollingIntervalMs({})).toBe(30000);
|
||||||
expect(getNotifyColumns({})).toEqual(["in-review"]);
|
expect(getNotifyColumns({})).toEqual(["in-review"]);
|
||||||
expect(getQuickCaptureColumn({})).toBe("triage");
|
expect(getQuickCaptureColumn({})).toBe("triage");
|
||||||
@@ -21,6 +23,9 @@ describe("settings accessors", () => {
|
|||||||
it("trims string values", () => {
|
it("trims string values", () => {
|
||||||
expect(getFusionBaseUrl({ fusionApiBaseUrl: " http://fusion.local:4040 " })).toBe("http://fusion.local:4040");
|
expect(getFusionBaseUrl({ fusionApiBaseUrl: " http://fusion.local:4040 " })).toBe("http://fusion.local:4040");
|
||||||
expect(getFusionToken({ fusionApiToken: " token " })).toBe("token");
|
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", () => {
|
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 { describe, expect, it, vi } from "vitest";
|
||||||
import { StubGlassesTransport } from "../transport.js";
|
import { WebhookGlassesTransport } from "../transport.js";
|
||||||
|
|
||||||
describe("StubGlassesTransport", () => {
|
describe("WebhookGlassesTransport", () => {
|
||||||
it("records pushes in order", async () => {
|
it("posts cards to companion webhook", async () => {
|
||||||
const transport = new StubGlassesTransport();
|
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: "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 () => {
|
it("tracks configuration and dispatches actions", async () => {
|
||||||
const transport = new StubGlassesTransport();
|
const transport = new WebhookGlassesTransport();
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
transport.onAction(handler);
|
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).toHaveBeenCalledTimes(1);
|
||||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ type: "quick-capture" }));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ export type GlassesCard = {
|
|||||||
actions?: GlassesCardAction[];
|
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> = {
|
const COLUMN_BADGES: Record<string, string> = {
|
||||||
triage: "triage",
|
triage: "triage",
|
||||||
todo: "todo",
|
todo: "todo",
|
||||||
@@ -27,18 +37,28 @@ const COLUMN_BADGES: Record<string, string> = {
|
|||||||
archived: "archived",
|
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;
|
const DEFAULT_MAX_TITLE = 80;
|
||||||
|
|
||||||
export function truncateLine(value: string, maxChars = DEFAULT_MAX_TITLE): string {
|
export function truncateLine(value: string, maxChars = DEFAULT_MAX_TITLE): string {
|
||||||
if (value.length <= maxChars) return value;
|
const trimmed = value.trim();
|
||||||
return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
|
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 maxCharsPerLine = Math.max(8, opts.maxCharsPerLine ?? 36);
|
||||||
const maxLines = Math.max(1, opts.maxLines ?? 2);
|
const maxLines = Math.max(1, opts.maxLines ?? 2);
|
||||||
const words = value.trim().split(/\s+/).filter(Boolean);
|
const words = value.trim().split(/\s+/).filter(Boolean);
|
||||||
if (words.length === 0) return [""];
|
if (words.length === 0) return [];
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
let current = "";
|
let current = "";
|
||||||
for (const word of words) {
|
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 && current) lines.push(current);
|
||||||
if (lines.length > maxLines) return lines.slice(0, maxLines);
|
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);
|
lines[lines.length - 1] = truncateLine(lines[lines.length - 1], maxCharsPerLine);
|
||||||
}
|
}
|
||||||
return lines;
|
return lines;
|
||||||
@@ -75,16 +95,43 @@ export function formatRelativeAge(updatedAt: string, opts: { now?: () => Date }
|
|||||||
return `updated ${Math.floor(hours / 24)}d ago`;
|
return `updated ${Math.floor(hours / 24)}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function taskToCard(task: Task): GlassesCard {
|
function boardSummary(tasks: Task[]): BoardSummary {
|
||||||
const title = typeof task.title === "string" && task.title.trim() ? task.title : task.description;
|
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 {
|
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",
|
kind: "task",
|
||||||
title: truncateLine(title, DEFAULT_MAX_TITLE),
|
title: truncateLine(`${task.id.toUpperCase()} ${title}`, maxCharsPerLine),
|
||||||
lines: [
|
lines: wrapLines(
|
||||||
`assignee: ${task.assignedAgentId ?? task.assigneeUserId ?? "unassigned"}`,
|
`Priority ${task.priority ?? "normal"} Assignee ${task.assignedAgentId ?? task.assigneeUserId ?? "unassigned"} Age ${formatRelativeAge(task.createdAt ?? task.updatedAt, { now: opts.now })}`,
|
||||||
formatRelativeAge(task.updatedAt),
|
{ maxCharsPerLine, maxLines },
|
||||||
],
|
),
|
||||||
badge: statusBadge(task.column),
|
badge: statusBadge(task.column),
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
updatedAt: task.updatedAt,
|
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 {
|
export function boardSummaryCard(tasksByColumn: Record<string, number>): GlassesCard {
|
||||||
const ordered = ["triage", "todo", "in-progress", "in-review", "done"];
|
const ordered = ["triage", "todo", "in-progress", "in-review", "done"];
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { definePlugin } from "@fusion/plugin-sdk";
|
import { definePlugin } from "@fusion/plugin-sdk";
|
||||||
import type { FusionPlugin, PluginContext, PluginRouteDefinition, PluginRouteResponse } 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 { 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 { quickCaptureRoutes } from "./routes/quick-capture-routes.js";
|
||||||
import { createNotificationRoutes } from "./routes/notification-routes.js";
|
import { createNotificationRoutes } from "./routes/notification-routes.js";
|
||||||
import { agentActionRoutes } from "./routes/agent-action-routes.js";
|
import { agentActionRoutes } from "./routes/agent-action-routes.js";
|
||||||
import { getFusionBaseUrl, getFusionToken, getNotifyColumns, settingsSchema } from "./settings.js";
|
import { boardRoutes } from "./routes/board-routes.js";
|
||||||
import { StubGlassesTransport } from "./transport.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 = {
|
export type PluginDb = {
|
||||||
exec(sql: string): void;
|
exec(sql: string): void;
|
||||||
@@ -18,12 +27,9 @@ export type PluginDb = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PluginInstance = {
|
type PluginInstance = {
|
||||||
client: FusionApiClient;
|
transport: WebhookGlassesTransport;
|
||||||
transport: StubGlassesTransport;
|
|
||||||
notifier: ReturnType<typeof createNotifier>;
|
notifier: ReturnType<typeof createNotifier>;
|
||||||
};
|
};const instances = new Map<string, PluginInstance>();
|
||||||
|
|
||||||
const instances = new Map<string, PluginInstance>();
|
|
||||||
|
|
||||||
function getDbFromTaskStore(ctx: PluginContext): PluginDb {
|
function getDbFromTaskStore(ctx: PluginContext): PluginDb {
|
||||||
const pluginStore = ctx.taskStore.getPluginStore();
|
const pluginStore = ctx.taskStore.getPluginStore();
|
||||||
@@ -49,6 +55,7 @@ const coreRoutes: PluginRouteDefinition[] = [
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
connected: instance.transport.connected,
|
connected: instance.transport.connected,
|
||||||
|
transport: instance.transport.status,
|
||||||
lastPollTime: instance.notifier.lastPolledAt() ?? null,
|
lastPollTime: instance.notifier.lastPolledAt() ?? null,
|
||||||
notifyOnColumns: getNotifyColumns(ctx.settings),
|
notifyOnColumns: getNotifyColumns(ctx.settings),
|
||||||
},
|
},
|
||||||
@@ -69,6 +76,7 @@ const coreRoutes: PluginRouteDefinition[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const notificationRoutes = createNotificationRoutes((ctx) => instances.get(ctx.pluginId)?.notifier);
|
const notificationRoutes = createNotificationRoutes((ctx) => instances.get(ctx.pluginId)?.notifier);
|
||||||
|
const transportRoutes = createTransportRoutes((ctx) => instances.get(ctx.pluginId)?.transport);
|
||||||
|
|
||||||
const plugin: FusionPlugin = definePlugin({
|
const plugin: FusionPlugin = definePlugin({
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -81,7 +89,7 @@ const plugin: FusionPlugin = definePlugin({
|
|||||||
settingsSchema,
|
settingsSchema,
|
||||||
},
|
},
|
||||||
state: "installed",
|
state: "installed",
|
||||||
routes: [...coreRoutes, ...quickCaptureRoutes, ...agentActionRoutes, ...notificationRoutes],
|
routes: [...coreRoutes, ...boardRoutes, ...quickCaptureRoutes, ...agentActionRoutes, ...notificationRoutes, ...transportRoutes],
|
||||||
hooks: {
|
hooks: {
|
||||||
onSchemaInit: (db) => {
|
onSchemaInit: (db) => {
|
||||||
(db as PluginDb).exec(`
|
(db as PluginDb).exec(`
|
||||||
@@ -99,9 +107,35 @@ const plugin: FusionPlugin = definePlugin({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const db = getDbFromTaskStore(ctx);
|
const db = getDbFromTaskStore(ctx);
|
||||||
const client = new FusionApiClient(getFusionBaseUrl(ctx.settings), token);
|
const transport = new WebhookGlassesTransport({
|
||||||
const transport = new StubGlassesTransport();
|
companionWebhookUrl: getCompanionWebhookUrl(ctx.settings),
|
||||||
|
});
|
||||||
await transport.connect();
|
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({
|
const notifier = createNotifier({
|
||||||
taskStore: ctx.taskStore,
|
taskStore: ctx.taskStore,
|
||||||
db,
|
db,
|
||||||
@@ -111,7 +145,7 @@ const plugin: FusionPlugin = definePlugin({
|
|||||||
pluginId: ctx.pluginId,
|
pluginId: ctx.pluginId,
|
||||||
});
|
});
|
||||||
notifier.start();
|
notifier.start();
|
||||||
instances.set(ctx.pluginId, { client, transport, notifier });
|
instances.set(ctx.pluginId, { transport, notifier });
|
||||||
},
|
},
|
||||||
onUnload: async () => {
|
onUnload: async () => {
|
||||||
for (const [pluginId, instance] of instances.entries()) {
|
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",
|
type: "string",
|
||||||
label: "Glasses Device ID",
|
label: "Glasses Device ID",
|
||||||
},
|
},
|
||||||
|
companionWebhookUrl: {
|
||||||
|
type: "string",
|
||||||
|
label: "Companion Webhook URL",
|
||||||
|
},
|
||||||
pollingIntervalSeconds: {
|
pollingIntervalSeconds: {
|
||||||
type: "number",
|
type: "number",
|
||||||
label: "Polling Interval (seconds)",
|
label: "Polling Interval (seconds)",
|
||||||
@@ -65,6 +69,10 @@ export function getFusionToken(settings: Record<string, unknown>): string | unde
|
|||||||
return getSettingString(settings, "fusionApiToken");
|
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 {
|
export function getPollingIntervalMs(settings: Record<string, unknown>): number {
|
||||||
const raw = settings.pollingIntervalSeconds;
|
const raw = settings.pollingIntervalSeconds;
|
||||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||||
|
|||||||
@@ -12,30 +12,99 @@ export interface GlassesTransport {
|
|||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
pushCard(card: GlassesCard): Promise<void>;
|
pushCard(card: GlassesCard): Promise<void>;
|
||||||
onAction(handler: (action: GlassesAction) => void | Promise<void>): 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>> = [];
|
private handlers: Array<(action: GlassesAction) => void | Promise<void>> = [];
|
||||||
public readonly pushedCards: GlassesCard[] = [];
|
private readonly fetchImpl: typeof fetch;
|
||||||
public connected = false;
|
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> {
|
async connect(): Promise<void> {
|
||||||
this.connected = true;
|
this._connected = true;
|
||||||
|
this._lastError = this.companionWebhookUrl ? null : "companionWebhookUrl not configured";
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
this.connected = false;
|
this._connected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async pushCard(card: GlassesCard): Promise<void> {
|
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 {
|
onAction(handler: (action: GlassesAction) => void | Promise<void>): void {
|
||||||
this.handlers.push(handler);
|
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) {
|
for (const handler of this.handlers) {
|
||||||
await handler(action);
|
await handler(action);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,4 @@ packages:
|
|||||||
- "plugins/fusion-plugin-whatsapp-chat"
|
- "plugins/fusion-plugin-whatsapp-chat"
|
||||||
- "plugins/fusion-plugin-roadmap"
|
- "plugins/fusion-plugin-roadmap"
|
||||||
- "plugins/fusion-plugin-even-realities-glasses"
|
- "plugins/fusion-plugin-even-realities-glasses"
|
||||||
- "plugins/fusion-plugin-even-cards"
|
|
||||||
- "plugins/fusion-plugin-reports"
|
- "plugins/fusion-plugin-reports"
|
||||||
|
|||||||
Reference in New Issue
Block a user