fix dashboard task payload freezes
This commit is contained in:
5
.changeset/fix-dashboard-task-payloads.md
Normal file
5
.changeset/fix-dashboard-task-payloads.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Reduce dashboard task payloads for search results and live task events to prevent frontend freezes on boards with large agent logs.
|
||||||
@@ -6504,6 +6504,37 @@ describe("searchTasks", () => {
|
|||||||
expect(results[0].description).toContain("homepage");
|
expect(results[0].description).toContain("homepage");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supports slim search results without loading task logs", async () => {
|
||||||
|
const uniqueTerm = `slimsearchpayload${Date.now()}`;
|
||||||
|
const task = await store.createTask({ description: `Slim search payload ${uniqueTerm}` });
|
||||||
|
await store.logEntry(task.id, "heavy log entry that should not appear in slim search");
|
||||||
|
|
||||||
|
const fullResults = await store.searchTasks(uniqueTerm);
|
||||||
|
const slimResults = await store.searchTasks(uniqueTerm, { slim: true });
|
||||||
|
const full = fullResults.find((result) => result.id === task.id)!;
|
||||||
|
const slim = slimResults.find((result) => result.id === task.id)!;
|
||||||
|
|
||||||
|
expect(full.log.length).toBeGreaterThan(0);
|
||||||
|
expect(slim.id).toBe(task.id);
|
||||||
|
expect(slim.log).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can exclude archived tasks from search results", async () => {
|
||||||
|
const uniqueTerm = `archivedsearchpayload${Date.now()}`;
|
||||||
|
const task = await store.createTask({ description: `Archived search payload ${uniqueTerm}` });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
await store.archiveTask(task.id);
|
||||||
|
|
||||||
|
const withArchived = await store.searchTasks(uniqueTerm);
|
||||||
|
const withoutArchived = await store.searchTasks(uniqueTerm, { includeArchived: false });
|
||||||
|
|
||||||
|
expect(withArchived.some((result) => result.id === task.id)).toBe(true);
|
||||||
|
expect(withoutArchived.some((result) => result.id === task.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("searches tasks by comment text", async () => {
|
it("searches tasks by comment text", async () => {
|
||||||
const task = await store.createTask({ description: "A task" });
|
const task = await store.createTask({ description: "A task" });
|
||||||
// Add a comment containing a unique word
|
// Add a comment containing a unique word
|
||||||
|
|||||||
@@ -277,6 +277,29 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getTaskSelectClause(slim: boolean, tableAlias?: string): string {
|
||||||
|
if (!slim) {
|
||||||
|
return tableAlias ? `${tableAlias}.*` : "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = tableAlias ? `${tableAlias}.` : "";
|
||||||
|
return [
|
||||||
|
"id", "title", "description", "\"column\"", "status", "size", "reviewLevel", "currentStep",
|
||||||
|
"worktree", "blockedBy", "paused", "baseBranch", "branch", "baseCommitSha",
|
||||||
|
"modelPresetId", "modelProvider", "modelId",
|
||||||
|
"validatorModelProvider", "validatorModelId",
|
||||||
|
"planningModelProvider", "planningModelId",
|
||||||
|
"mergeRetries", "stuckKillCount", "recoveryRetryCount", "nextRecoveryAt",
|
||||||
|
"error", "summary", "thinkingLevel",
|
||||||
|
"createdAt", "updatedAt", "columnMovedAt",
|
||||||
|
"dependencies", "steps", "comments", "workflowStepResults", "steeringComments",
|
||||||
|
"attachments", "prInfo", "issueInfo", "mergeDetails",
|
||||||
|
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
|
||||||
|
"missionId", "sliceId", "assignedAgentId", "assigneeUserId",
|
||||||
|
"checkedOutBy", "checkedOutAt",
|
||||||
|
].map((column) => `${prefix}${column}`).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert a task to the database. Used by create and update operations.
|
* Upsert a task to the database. Used by create and update operations.
|
||||||
*/
|
*/
|
||||||
@@ -1334,22 +1357,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
// - `steeringComments` → steering badge
|
// - `steeringComments` → steering badge
|
||||||
// Use `getTask(id)` to load the full row (including `log`) for the
|
// Use `getTask(id)` to load the full row (including `log`) for the
|
||||||
// TaskDetailModal's Activity tab and Agent Log subview.
|
// TaskDetailModal's Activity tab and Agent Log subview.
|
||||||
const slimColumns = `
|
const selectClause = this.getTaskSelectClause(slim);
|
||||||
id, title, description, "column", status, size, reviewLevel, currentStep,
|
|
||||||
worktree, blockedBy, paused, baseBranch, branch, baseCommitSha,
|
|
||||||
modelPresetId, modelProvider, modelId,
|
|
||||||
validatorModelProvider, validatorModelId,
|
|
||||||
planningModelProvider, planningModelId,
|
|
||||||
mergeRetries, stuckKillCount, recoveryRetryCount, nextRecoveryAt,
|
|
||||||
error, summary, thinkingLevel,
|
|
||||||
createdAt, updatedAt, columnMovedAt,
|
|
||||||
dependencies, steps, comments, workflowStepResults, steeringComments,
|
|
||||||
attachments, prInfo, issueInfo, mergeDetails,
|
|
||||||
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles,
|
|
||||||
missionId, sliceId, assignedAgentId, assigneeUserId,
|
|
||||||
checkedOutBy, checkedOutAt
|
|
||||||
`;
|
|
||||||
const selectClause = slim ? slimColumns : '*';
|
|
||||||
const whereParts: string[] = [];
|
const whereParts: string[] = [];
|
||||||
const params: string[] = [];
|
const params: string[] = [];
|
||||||
if (columnFilter) {
|
if (columnFilter) {
|
||||||
@@ -1399,7 +1407,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
* @param query - The search query string
|
* @param query - The search query string
|
||||||
* @param options - Optional limit and offset for pagination
|
* @param options - Optional limit and offset for pagination
|
||||||
*/
|
*/
|
||||||
async searchTasks(query: string, options?: { limit?: number; offset?: number }): Promise<Task[]> {
|
async searchTasks(query: string, options?: { limit?: number; offset?: number; slim?: boolean; includeArchived?: boolean }): Promise<Task[]> {
|
||||||
// Fall back to listTasks for empty/whitespace-only queries
|
// Fall back to listTasks for empty/whitespace-only queries
|
||||||
const trimmedQuery = query?.trim();
|
const trimmedQuery = query?.trim();
|
||||||
if (!trimmedQuery) {
|
if (!trimmedQuery) {
|
||||||
@@ -1433,11 +1441,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
const limit = options?.limit ?? -1;
|
const limit = options?.limit ?? -1;
|
||||||
const offset = options?.offset ?? 0;
|
const offset = options?.offset ?? 0;
|
||||||
const offsetClause = offset > 0 ? ` OFFSET ${offset}` : "";
|
const offsetClause = offset > 0 ? ` OFFSET ${offset}` : "";
|
||||||
|
const includeArchived = options?.includeArchived ?? true;
|
||||||
|
const whereClause = includeArchived ? "" : ` AND t."column" != 'archived'`;
|
||||||
|
const selectClause = this.getTaskSelectClause(options?.slim ?? false, "t");
|
||||||
|
|
||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
SELECT t.* FROM tasks t
|
SELECT ${selectClause} FROM tasks t
|
||||||
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
||||||
WHERE tasks_fts MATCH ?
|
WHERE tasks_fts MATCH ?
|
||||||
|
${whereClause}
|
||||||
ORDER BY rank
|
ORDER BY rank
|
||||||
LIMIT ${limit >= 0 ? limit : -1}${offsetClause}
|
LIMIT ${limit >= 0 ? limit : -1}${offsetClause}
|
||||||
`).all(ftsQuery) as any[];
|
`).all(ftsQuery) as any[];
|
||||||
|
|||||||
@@ -125,6 +125,34 @@ describe("createSSE", () => {
|
|||||||
expect(sseMsg).toBeDefined();
|
expect(sseMsg).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips heavy task logs from task event payloads", () => {
|
||||||
|
const req = createMockRequest();
|
||||||
|
const { res, chunks } = createMockResponse();
|
||||||
|
createSSE(store)(req, res);
|
||||||
|
|
||||||
|
store.emit("task:updated", {
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Updated",
|
||||||
|
log: [{ action: "very large log entry", timestamp: new Date().toISOString() }],
|
||||||
|
});
|
||||||
|
store.emit("task:moved", {
|
||||||
|
task: {
|
||||||
|
id: "FN-001",
|
||||||
|
log: [{ action: "another large log entry", timestamp: new Date().toISOString() }],
|
||||||
|
},
|
||||||
|
from: "todo",
|
||||||
|
to: "in-progress",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedMsg = chunks.find((c) => c.includes("task:updated"))!;
|
||||||
|
const movedMsg = chunks.find((c) => c.includes("task:moved"))!;
|
||||||
|
|
||||||
|
expect(extractSSEPayload(updatedMsg).log).toEqual([]);
|
||||||
|
expect(extractSSEPayload(movedMsg).task.log).toEqual([]);
|
||||||
|
expect(updatedMsg).not.toContain("very large log entry");
|
||||||
|
expect(movedMsg).not.toContain("another large log entry");
|
||||||
|
});
|
||||||
|
|
||||||
it("relays task:deleted events as SSE messages", () => {
|
it("relays task:deleted events as SSE messages", () => {
|
||||||
const req = createMockRequest();
|
const req = createMockRequest();
|
||||||
const { res, chunks } = createMockResponse();
|
const { res, chunks } = createMockResponse();
|
||||||
|
|||||||
@@ -220,7 +220,12 @@ describe("GET /tasks", () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toHaveLength(1);
|
expect(res.body).toHaveLength(1);
|
||||||
expect(store.searchTasks).toHaveBeenCalledWith("FN-001", { limit: undefined, offset: undefined });
|
expect(store.searchTasks).toHaveBeenCalledWith("FN-001", {
|
||||||
|
limit: undefined,
|
||||||
|
offset: undefined,
|
||||||
|
slim: true,
|
||||||
|
includeArchived: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns tasks for search query with limit", async () => {
|
it("returns tasks for search query with limit", async () => {
|
||||||
@@ -230,7 +235,12 @@ describe("GET /tasks", () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toHaveLength(1);
|
expect(res.body).toHaveLength(1);
|
||||||
expect(store.searchTasks).toHaveBeenCalledWith("something", { limit: 5, offset: undefined });
|
expect(store.searchTasks).toHaveBeenCalledWith("something", {
|
||||||
|
limit: 5,
|
||||||
|
offset: undefined,
|
||||||
|
slim: true,
|
||||||
|
includeArchived: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty array for non-existent search query", async () => {
|
it("returns empty array for non-existent search query", async () => {
|
||||||
|
|||||||
@@ -2302,10 +2302,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
|
|
||||||
let tasks;
|
let tasks;
|
||||||
if (q && q.length > 0) {
|
if (q && q.length > 0) {
|
||||||
tasks = await scopedStore.searchTasks(q, { limit, offset });
|
tasks = await scopedStore.searchTasks(q, { limit, offset, slim: true, includeArchived });
|
||||||
} else {
|
} else {
|
||||||
// Board-view list: omit heavy fields (log/comments/steps/workflowStepResults) and
|
// Board-view list: omit the heavy agent log payload and exclude
|
||||||
// exclude archived tasks unless explicitly requested. Full task detail still loads via
|
// archived tasks unless explicitly requested. Full task detail still loads via
|
||||||
// GET /api/tasks/:id. Without this, every dashboard load shipped tens of MB of agent logs.
|
// GET /api/tasks/:id. Without this, every dashboard load shipped tens of MB of agent logs.
|
||||||
tasks = await scopedStore.listTasks({ limit, offset, slim: true, includeArchived });
|
tasks = await scopedStore.listTasks({ limit, offset, slim: true, includeArchived });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,34 @@ function safeWrite(res: Response, data: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripTaskListHeavyFields<T>(task: T): T {
|
||||||
|
if (!task || typeof task !== "object" || Array.isArray(task)) {
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("log" in task)) {
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...task, log: [] } as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTaskEventHeavyFields<T>(payload: T): T {
|
||||||
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = payload as Record<string, unknown>;
|
||||||
|
if ("task" in candidate) {
|
||||||
|
return {
|
||||||
|
...candidate,
|
||||||
|
task: stripTaskListHeavyFields(candidate.task),
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripTaskListHeavyFields(payload);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalized plugin lifecycle transition types.
|
* Normalized plugin lifecycle transition types.
|
||||||
* These are the unified set of transitions that the SSE stream emits.
|
* These are the unified set of transitions that the SSE stream emits.
|
||||||
@@ -161,19 +189,19 @@ export function createSSE(
|
|||||||
// --- Event handler definitions ---
|
// --- Event handler definitions ---
|
||||||
|
|
||||||
const onCreated = (task: any) => {
|
const onCreated = (task: any) => {
|
||||||
send(`event: task:created\ndata: ${JSON.stringify(task)}\n\n`);
|
send(`event: task:created\ndata: ${JSON.stringify(stripTaskListHeavyFields(task))}\n\n`);
|
||||||
};
|
};
|
||||||
const onMoved = (data: any) => {
|
const onMoved = (data: any) => {
|
||||||
send(`event: task:moved\ndata: ${JSON.stringify(data)}\n\n`);
|
send(`event: task:moved\ndata: ${JSON.stringify(stripTaskEventHeavyFields(data))}\n\n`);
|
||||||
};
|
};
|
||||||
const onUpdated = (task: any) => {
|
const onUpdated = (task: any) => {
|
||||||
send(`event: task:updated\ndata: ${JSON.stringify(task)}\n\n`);
|
send(`event: task:updated\ndata: ${JSON.stringify(stripTaskListHeavyFields(task))}\n\n`);
|
||||||
};
|
};
|
||||||
const onDeleted = (task: any) => {
|
const onDeleted = (task: any) => {
|
||||||
send(`event: task:deleted\ndata: ${JSON.stringify(task)}\n\n`);
|
send(`event: task:deleted\ndata: ${JSON.stringify(stripTaskListHeavyFields(task))}\n\n`);
|
||||||
};
|
};
|
||||||
const onMerged = (result: any) => {
|
const onMerged = (result: any) => {
|
||||||
send(`event: task:merged\ndata: ${JSON.stringify(result)}\n\n`);
|
send(`event: task:merged\ndata: ${JSON.stringify(stripTaskEventHeavyFields(result))}\n\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMissionCreated = (data: any) => {
|
const onMissionCreated = (data: any) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user