feat(FN-3796): add plugin routes wiring and listTasksModifiedSince

Merged plugin infrastructure improvements: FN-3796 adds a complete plugin route wiring system with typed `pluginRunner`, `listTasksModifiedSince` store support, and comprehensive test coverage across core and dashboard packages. FN-3794 isolates WhatsApp sessions per project with unload context prop

Fusion-Task-Id: FN-3796
This commit is contained in:
Fusion
2026-05-09 01:03:29 -07:00
committed by gsxdsm
parent 7d20a348d8
commit de174491bb
7 changed files with 325 additions and 1 deletions

View File

@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "../store.js";
describe("TaskStore.listTasksModifiedSince", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = await mkdtemp(join(tmpdir(), "store-list-modified-"));
globalDir = join(rootDir, ".fusion-global-settings");
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
await store.close();
await rm(rootDir, { recursive: true, force: true });
});
async function createTaskWithUpdatedAt(id: string, updatedAt: string, column: "todo" | "archived" = "todo") {
return store.createTaskWithReservedId(
{ description: `Task ${id}`, column },
{ taskId: id, createdAt: updatedAt, updatedAt },
);
}
it("returns an empty array for an empty store", async () => {
const changes = await store.listTasksModifiedSince("2026-01-01T00:00:00.000Z", 50);
expect(changes).toEqual([]);
});
it("returns no rows when all updatedAt are <= since", async () => {
await createTaskWithUpdatedAt("FN-1", "2026-01-01T00:00:00.000Z");
await createTaskWithUpdatedAt("FN-2", "2026-01-01T00:00:00.500Z");
const changes = await store.listTasksModifiedSince("2026-01-01T00:00:00.500Z", 50);
expect(changes).toEqual([]);
});
it("uses a strict greater-than cursor boundary", async () => {
const since = "2026-01-01T00:00:00.000Z";
await createTaskWithUpdatedAt("FN-1", since);
await createTaskWithUpdatedAt("FN-2", "2026-01-01T00:00:00.001Z");
const changes = await store.listTasksModifiedSince(since, 50);
expect(changes.map((task) => task.id)).toEqual(["FN-2"]);
});
it("returns tasks in updatedAt ascending order", async () => {
await createTaskWithUpdatedAt("FN-1", "2026-01-01T00:00:00.003Z");
await createTaskWithUpdatedAt("FN-2", "2026-01-01T00:00:00.001Z");
await createTaskWithUpdatedAt("FN-3", "2026-01-01T00:00:00.002Z");
const changes = await store.listTasksModifiedSince("2026-01-01T00:00:00.000Z", 50);
expect(changes.map((task) => task.id)).toEqual(["FN-2", "FN-3", "FN-1"]);
expect(changes.map((task) => task.updatedAt)).toEqual([
"2026-01-01T00:00:00.001Z",
"2026-01-01T00:00:00.002Z",
"2026-01-01T00:00:00.003Z",
]);
});
it("applies the limit cap to earliest modified tasks", async () => {
for (let i = 1; i <= 5; i += 1) {
await createTaskWithUpdatedAt(`FN-${i}`, `2026-01-01T00:00:00.00${i}Z`);
}
const changes = await store.listTasksModifiedSince("2026-01-01T00:00:00.000Z", 2);
expect(changes.map((task) => task.id)).toEqual(["FN-1", "FN-2"]);
});
it.each([
["limit=0", "2026-01-01T00:00:00.000Z", 0, "finite positive integer"],
["limit=-1", "2026-01-01T00:00:00.000Z", -1, "finite positive integer"],
["limit=201", "2026-01-01T00:00:00.000Z", 201, "less than or equal to 200"],
["limit non-numeric", "2026-01-01T00:00:00.000Z", Number.NaN, "finite positive integer"],
["since non-string", 123 as unknown as string, 50, "since must be a non-empty string"],
["since empty", "", 50, "since must be a non-empty string"],
])("validates inputs: %s", async (_name, since, limit, message) => {
await expect(store.listTasksModifiedSince(since, limit as number)).rejects.toThrow(TypeError);
await expect(store.listTasksModifiedSince(since, limit as number)).rejects.toThrow(message);
});
it("applies slim mode log stripping and timedExecution aggregation", async () => {
await createTaskWithUpdatedAt("FN-1", "2026-01-01T00:00:00.000Z");
await store.logEntry("FN-1", "[timing] Step finished in 123ms");
const full = await store.listTasksModifiedSince("2026-01-01T00:00:00.000Z", 50);
expect(full).toHaveLength(1);
expect(full[0]?.log.length).toBeGreaterThan(0);
const slim = await store.listTasksModifiedSince("2026-01-01T00:00:00.000Z", 50, { slim: true });
expect(slim).toHaveLength(1);
expect(slim[0]?.log).toEqual([]);
expect(typeof slim[0]?.timedExecutionMs).toBe("number");
expect(slim[0]?.timedExecutionMs).toBeGreaterThan(0);
});
it("excludes archived-column tasks", async () => {
await createTaskWithUpdatedAt("FN-1", "2026-01-01T00:00:00.001Z");
await createTaskWithUpdatedAt("FN-2", "2026-01-01T00:00:00.002Z", "archived");
const changes = await store.listTasksModifiedSince("2026-01-01T00:00:00.000Z", 50);
expect(changes.map((task) => task.id)).toEqual(["FN-1"]);
});
});

View File

@@ -2710,6 +2710,50 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return sorted.slice(offset, offset + Math.max(0, limit));
}
/** List only live-board tasks modified after `since`; archived snapshots in archiveDb are excluded. */
async listTasksModifiedSince(
since: string,
limit: number,
opts?: { projectId?: string; slim?: boolean },
): Promise<Task[]> {
if (typeof since !== "string" || since.trim().length === 0) {
throw new TypeError("since must be a non-empty string");
}
const resolvedLimit = limit === undefined ? 50 : limit;
if (!Number.isFinite(resolvedLimit) || !Number.isInteger(resolvedLimit) || resolvedLimit <= 0) {
throw new TypeError("limit must be a finite positive integer");
}
if (resolvedLimit > 200) {
throw new TypeError("limit must be less than or equal to 200");
}
// projectId is reserved for future cross-project queries.
void opts?.projectId;
const slim = opts?.slim === true;
const selectClause = this.getTaskSelectClause(slim);
const rows = this.db.prepare(
`SELECT ${selectClause} FROM tasks WHERE updatedAt > ? AND "column" != 'archived' ORDER BY updatedAt ASC LIMIT ?`,
).all(since, resolvedLimit) as TaskRow[];
return Promise.all(rows.map(async (row) => {
const task = this.rowToTask(row);
if (slim) {
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
task.log = [];
}
if (!slim || task.steps.length > 0) {
return task;
}
const steps = await this.parseStepsFromPrompt(task.id);
return steps.length > 0 ? { ...task, steps } : task;
}));
}
/**
* Returns the ID of a task currently in an active merge status ("merging" or
* "merging-pr"), optionally excluding a specific task ID.

View File

@@ -0,0 +1,133 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { PluginLoader, PluginRunner } from "@fusion/core";
import { TaskStore } from "@fusion/core";
import { createApiRoutes } from "../routes.js";
import { createAuthMiddleware } from "../auth-middleware.js";
import { get as performGet, request as performRequest } from "../test-request.js";
describe("createApiRoutes plugin route wiring", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
let originalDaemonToken: string | undefined;
const pluginId = "wire-test-plugin";
beforeEach(async () => {
rootDir = await mkdtemp(join(tmpdir(), "plugin-routes-wiring-"));
globalDir = join(rootDir, ".fusion-global-settings");
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
originalDaemonToken = process.env.FUSION_DAEMON_TOKEN;
delete process.env.FUSION_DAEMON_TOKEN;
const pluginStore = store.getPluginStore();
await pluginStore.registerPlugin({
manifest: {
id: pluginId,
name: "Wire Test Plugin",
version: "1.0.0",
description: "Plugin route wiring test",
},
path: rootDir,
});
});
afterEach(async () => {
if (originalDaemonToken === undefined) {
delete process.env.FUSION_DAEMON_TOKEN;
} else {
process.env.FUSION_DAEMON_TOKEN = originalDaemonToken;
}
await store.close();
await rm(rootDir, { recursive: true, force: true });
});
function buildApp(options?: { token?: string }) {
const pluginLoader = {
getPlugin: vi.fn().mockReturnValue({ manifest: { id: pluginId } }),
createRouteContext: vi.fn().mockImplementation(async () => ({
pluginId,
taskStore: store,
settings: {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
})),
} as unknown as PluginLoader;
const pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([
{
pluginId,
route: {
method: "GET",
path: "/hello",
handler: vi.fn().mockResolvedValue({ ok: true }),
},
},
]),
} as unknown as PluginRunner;
const app = express();
app.use(express.json());
if (options?.token) {
process.env.FUSION_DAEMON_TOKEN = options.token;
app.use(createAuthMiddleware(options.token));
}
app.use("/api", createApiRoutes(store, {
pluginStore: store.getPluginStore(),
pluginLoader,
pluginRunner,
}));
app.use((_req, res) => {
res.status(404).json({ error: "Not found" });
});
return app;
}
it("routes plugin-defined endpoints through createApiRoutes mount", async () => {
const app = buildApp();
const ok = await performGet(app, `/api/plugins/${pluginId}/hello`);
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ ok: true });
});
it("returns 404 for unknown plugin route paths", async () => {
const app = buildApp();
const missing = await performGet(app, `/api/plugins/${pluginId}/does-not-exist`);
expect(missing.status).toBe(404);
});
it.each([
["missing token", undefined, 401],
["invalid token", "Bearer wrong-token", 401],
["valid token", "Bearer fn_valid_token_123", 200],
])("enforces bearer auth when daemon token is enabled: %s", async (_label, authHeader, expectedStatus) => {
const app = buildApp({ token: "fn_valid_token_123" });
const headers = authHeader ? { Authorization: authHeader } : undefined;
const response = await performRequest(app, "GET", `/api/plugins/${pluginId}/hello`, undefined, headers);
expect(response.status).toBe(expectedStatus);
if (expectedStatus === 200) {
expect(response.body).toEqual({ ok: true });
}
});
it("keeps management plugin lookup route reachable", async () => {
const app = buildApp();
const res = await performGet(app, `/api/plugins/${pluginId}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: pluginId, name: "Wire Test Plugin" });
});
});

View File

@@ -57,6 +57,15 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
invokeHook: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
createRouteContext: vi.fn().mockImplementation(async (pluginId: string, ctx: Record<string, unknown>) => ({
pluginId,
taskStore: ctx.taskStore,
settings: ctx.settings ?? {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
createAiSession: (ctx as { createAiSession?: unknown }).createAiSession,
resolveProjectTaskStore: ctx.resolveProjectTaskStore,
})),
...overrides,
} as unknown as PluginLoader;
}

View File

@@ -105,6 +105,15 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
invokeHook: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
createRouteContext: vi.fn().mockImplementation(async (pluginId: string, ctx: Record<string, unknown>) => ({
pluginId,
taskStore: ctx.taskStore,
settings: ctx.settings ?? {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
createAiSession: await fusionCore.getCreateAiSessionFactory(),
resolveProjectTaskStore: ctx.resolveProjectTaskStore,
})),
...overrides,
} as unknown as PluginLoader;
}

View File

@@ -50,7 +50,7 @@ import {
sendErrorResponse,
unauthorized,
} from "./api-error.js";
import { resolvePluginManifest } from "./plugin-routes.js";
import { createPluginRouter, resolvePluginManifest } from "./plugin-routes.js";
import { hermesRuntimeMetadata } from "@fusion-plugin-examples/hermes-runtime";
import { openclawRuntimeMetadata } from "@fusion-plugin-examples/openclaw-runtime";
@@ -4464,6 +4464,19 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// precedence unchanged relative to existing wildcard handlers.
registerIntegratedDevServerRouter({ router, store });
if (options?.pluginStore && options?.pluginLoader) {
const pluginRunner = options.pluginRunner as Parameters<typeof createPluginRouter>[2];
router.use(
"/plugins",
createPluginRouter(
options.pluginStore,
options.pluginLoader,
pluginRunner,
store,
),
);
}
// Scripts and messaging routes are registered by registerMessagingScriptRoutes().
router.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {