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:
111
packages/core/src/__tests__/store-list-modified.test.ts
Normal file
111
packages/core/src/__tests__/store-list-modified.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user