Commits merged: - feat(FN-3743): add polling task notifications for even realities plugin Files changed: .../fusion-plugin-even-realities-glasses/README.md | 27 +++ .../package.json | 2 + .../src/__tests__/cards.test.ts | 82 +++------ .../src/__tests__/diff.test.ts | 76 ++++++++ .../src/__tests__/notification-card.test.ts | 40 +++++ .../src/__tests__/notification-routes.test.ts | 78 ++++++++ .../src/__tests__/notification-store.test.ts | 71 ++++++++ .../src/__tests__/notifier.test.ts | 138 +++++++++----- .../src/__tests__/transport.test.ts | 4 +- .../src/agent-actions.ts | 4 +- .../src/cards.ts | 127 ++++++++++--- .../src/index.ts | 22 ++- .../src/notifications/diff.ts | 77 ++++++++ .../src/notifications/store.ts | 46 +++++ .../src/notifications/types.ts | 19 ++ .../src/notifier.ts | 199 +++++++++++++-------- .../src/routes/notification-routes.ts | 103 +++++++++++ pnpm-lock.yaml | 31 +++- 18 files changed, 929 insertions(+), 217 deletions(-) Fusion-Task-Id: FN-3743
157 lines
4.8 KiB
TypeScript
157 lines
4.8 KiB
TypeScript
import type { Task } from "@fusion/core";
|
|
import type { PluginContext } from "@fusion/plugin-sdk";
|
|
import { notificationCard } from "./cards.js";
|
|
import { diffSnapshots } from "./notifications/diff.js";
|
|
import { pruneMissing, readSnapshot, writeSnapshot } from "./notifications/store.js";
|
|
import type { NotificationEvent } from "./notifications/types.js";
|
|
import type { PluginDb } from "./index.js";
|
|
import { getNotifyColumns, getPollingIntervalMs } from "./settings.js";
|
|
import type { GlassesTransport } from "./transport.js";
|
|
|
|
export interface NotifierDeps {
|
|
taskStore: PluginContext["taskStore"];
|
|
db: PluginDb;
|
|
transport: GlassesTransport;
|
|
settings: PluginContext["settings"];
|
|
logger?: PluginContext["logger"];
|
|
pluginId: string;
|
|
now?: () => Date;
|
|
setIntervalImpl?: typeof setInterval;
|
|
clearIntervalImpl?: typeof clearInterval;
|
|
}
|
|
|
|
export interface Notifier {
|
|
start(): void;
|
|
stop(): Promise<void>;
|
|
pollOnce(): Promise<NotificationEvent[]>;
|
|
peekPending(limit?: number): NotificationEvent[];
|
|
drainPending(limit?: number): NotificationEvent[];
|
|
ack(taskIds: ReadonlySet<string>): number;
|
|
lastPolledAt(): string | null;
|
|
getLastPollTime(): string | null;
|
|
}
|
|
|
|
const PENDING_CAP = 200;
|
|
|
|
export function createNotifier(deps: NotifierDeps): Notifier {
|
|
const setIntervalImpl = deps.setIntervalImpl ?? setInterval;
|
|
const clearIntervalImpl = deps.clearIntervalImpl ?? clearInterval;
|
|
|
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
let inFlight = false;
|
|
let running = false;
|
|
let stopped = false;
|
|
let inFlightPromise: Promise<NotificationEvent[]> | null = null;
|
|
let pending: NotificationEvent[] = [];
|
|
let lastPollIso: string | null = null;
|
|
|
|
const nowIso = () => (deps.now?.() ?? new Date()).toISOString();
|
|
|
|
const bounded = (items: NotificationEvent[]) => {
|
|
if (items.length <= PENDING_CAP) return items;
|
|
return items.slice(items.length - PENDING_CAP);
|
|
};
|
|
|
|
const performPoll = async (): Promise<NotificationEvent[]> => {
|
|
if (stopped) return [];
|
|
if (inFlight) {
|
|
deps.logger?.debug?.("skip overlapping poll", { pluginId: deps.pluginId });
|
|
return [];
|
|
}
|
|
|
|
inFlight = true;
|
|
try {
|
|
const tasks = (await deps.taskStore.listTasks({ includeArchived: false })) as Task[];
|
|
const snapshot = readSnapshot(deps.db);
|
|
const notifyOnColumns = new Set(getNotifyColumns(deps.settings));
|
|
const events = diffSnapshots(snapshot, tasks, { notifyOnColumns, alsoNotifyOnDone: false });
|
|
const taskMap = new Map(tasks.map((task) => [task.id, task] as const));
|
|
|
|
for (const event of events) {
|
|
const task = taskMap.get(event.taskId);
|
|
if (!task) continue;
|
|
try {
|
|
await deps.transport.pushCard(notificationCard(task, event.reason));
|
|
} catch (err) {
|
|
deps.logger?.error?.("failed to push notification card", { err, pluginId: deps.pluginId, taskId: task.id });
|
|
}
|
|
}
|
|
|
|
pending = bounded([...pending, ...events]);
|
|
writeSnapshot(
|
|
deps.db,
|
|
tasks.map((task) => ({ taskId: task.id, lastColumn: task.column, updatedAt: task.updatedAt })),
|
|
);
|
|
pruneMissing(deps.db, new Set(tasks.map((task) => task.id)));
|
|
lastPollIso = nowIso();
|
|
return events;
|
|
} catch (err) {
|
|
deps.logger?.error?.("notifier poll failed", { err, pluginId: deps.pluginId });
|
|
return [];
|
|
} finally {
|
|
inFlight = false;
|
|
}
|
|
};
|
|
|
|
return {
|
|
start() {
|
|
if (running) return;
|
|
running = true;
|
|
stopped = false;
|
|
void this.pollOnce();
|
|
const intervalMs = Math.max(5000, getPollingIntervalMs(deps.settings));
|
|
timer = setIntervalImpl(() => {
|
|
if (stopped) return;
|
|
void this.pollOnce();
|
|
}, intervalMs);
|
|
},
|
|
|
|
async stop() {
|
|
if (!running && !timer) return;
|
|
stopped = true;
|
|
running = false;
|
|
if (timer) {
|
|
clearIntervalImpl(timer);
|
|
timer = undefined;
|
|
}
|
|
if (inFlightPromise) {
|
|
await inFlightPromise.catch(() => undefined);
|
|
}
|
|
},
|
|
|
|
pollOnce() {
|
|
const poll = performPoll();
|
|
inFlightPromise = poll;
|
|
return poll.finally(() => {
|
|
if (inFlightPromise === poll) inFlightPromise = null;
|
|
});
|
|
},
|
|
|
|
peekPending(limit = 50) {
|
|
const max = Math.max(0, Math.floor(limit));
|
|
return pending.slice(0, max);
|
|
},
|
|
|
|
drainPending(limit = 50) {
|
|
const max = Math.max(0, Math.floor(limit));
|
|
const drained = pending.slice(0, max);
|
|
pending = pending.slice(drained.length);
|
|
return drained;
|
|
},
|
|
|
|
ack(taskIds: ReadonlySet<string>) {
|
|
const before = pending.length;
|
|
pending = pending.filter((event) => !taskIds.has(event.taskId));
|
|
return before - pending.length;
|
|
},
|
|
|
|
lastPolledAt() {
|
|
return lastPollIso;
|
|
},
|
|
|
|
getLastPollTime() {
|
|
return lastPollIso;
|
|
},
|
|
};
|
|
}
|