perf(dashboard): slim task list + auto-archive stale done tasks
GET /api/tasks was returning ~69 MB of JSON per call (67.9 MB of agent logs across 1199 tasks), causing the dashboard to hang for 2+ minutes. - core: extend listTasks() with slim and includeArchived options - dashboard: GET /api/tasks now uses slim mode and excludes archived by default; ?includeArchived=1 opts in - frontend: lazy-load archived tasks when the archived column is first expanded via new useTasks.loadArchivedTasks() - engine: self-healing maintenance now auto-archives done tasks older than 48h (data stays in SQLite, column flips done -> archived) - tests: slim mode + includeArchived coverage in store.test.ts; routes.test.ts assertion updated for new args Also bundles in-progress test-setup noise filters and pre-existing QuickEntryBox/routes test work that was already modified locally. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1322,6 +1322,42 @@ describe("TaskStore", () => {
|
||||
expect(paged).toHaveLength(1);
|
||||
expect(paged[0].id).toBe("FN-002");
|
||||
});
|
||||
|
||||
it("slim mode returns metadata but drops heavy fields (log/comments/steps)", async () => {
|
||||
const task = await store.createTask({ description: "Slim test" });
|
||||
await store.logEntry(task.id, "heavy log entry that should not appear in slim list");
|
||||
|
||||
const fullList = await store.listTasks();
|
||||
const slimList = await store.listTasks({ slim: true });
|
||||
|
||||
const full = fullList.find((t) => t.id === task.id)!;
|
||||
const slim = slimList.find((t) => t.id === task.id)!;
|
||||
|
||||
expect(full.log.length).toBeGreaterThan(0);
|
||||
expect(slim.id).toBe(task.id);
|
||||
expect(slim.description).toBe("Slim test");
|
||||
expect(slim.column).toBe(full.column);
|
||||
expect(slim.log).toEqual([]);
|
||||
expect(slim.steps).toEqual([]);
|
||||
expect(slim.comments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includeArchived=false excludes archived tasks; default includes them", async () => {
|
||||
const keep = await store.createTask({ description: "Stays visible" });
|
||||
const toArchive = await store.createTask({ description: "Will be archived" });
|
||||
await store.moveTask(toArchive.id, "todo");
|
||||
await store.moveTask(toArchive.id, "in-progress");
|
||||
await store.moveTask(toArchive.id, "in-review");
|
||||
await store.moveTask(toArchive.id, "done");
|
||||
await store.archiveTask(toArchive.id);
|
||||
|
||||
const withArchived = await store.listTasks();
|
||||
const withoutArchived = await store.listTasks({ includeArchived: false });
|
||||
|
||||
expect(withArchived.map((t) => t.id)).toEqual(expect.arrayContaining([keep.id, toArchive.id]));
|
||||
expect(withoutArchived.map((t) => t.id)).toContain(keep.id);
|
||||
expect(withoutArchived.map((t) => t.id)).not.toContain(toArchive.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQLite-first reads when task blobs are missing", () => {
|
||||
|
||||
@@ -1307,8 +1307,39 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return { ...task, prompt };
|
||||
}
|
||||
|
||||
async listTasks(options?: { limit?: number; offset?: number }): Promise<Task[]> {
|
||||
const rows = this.db.prepare('SELECT * FROM tasks ORDER BY createdAt ASC').all();
|
||||
async listTasks(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
/** When false, exclude tasks in the `archived` column. Default: true (backward compatible). */
|
||||
includeArchived?: boolean;
|
||||
/** When true, omit heavy fields (log, comments, steps, workflowStepResults, steeringComments)
|
||||
* from each row to make list responses cheap for board-style consumers. Detail fields default
|
||||
* to empty arrays in the returned Task objects; use `getTask(id)` to load full data. */
|
||||
slim?: boolean;
|
||||
}): Promise<Task[]> {
|
||||
const includeArchived = options?.includeArchived ?? true;
|
||||
const slim = options?.slim ?? false;
|
||||
|
||||
const slimColumns = `
|
||||
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,
|
||||
attachments, prInfo, issueInfo, mergeDetails,
|
||||
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles,
|
||||
missionId, sliceId, assignedAgentId, assigneeUserId,
|
||||
checkedOutBy, checkedOutAt
|
||||
`;
|
||||
const selectClause = slim ? slimColumns : '*';
|
||||
const whereClause = includeArchived ? '' : ` WHERE "column" != 'archived'`;
|
||||
const sql = `SELECT ${selectClause} FROM tasks${whereClause} ORDER BY createdAt ASC`;
|
||||
|
||||
const rows = this.db.prepare(sql).all();
|
||||
const tasks = (rows as any[]).map((row) => this.rowToTask(row));
|
||||
|
||||
// Sort by createdAt, then by numeric ID suffix for tie-breaking
|
||||
|
||||
@@ -71,7 +71,7 @@ function AppInner() {
|
||||
const effectiveTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : [];
|
||||
|
||||
// Tasks hook with project context and search query
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks(
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks } = useTasks(
|
||||
currentProject ? { projectId: currentProject.id, searchQuery: searchQuery || undefined } : { searchQuery: searchQuery || undefined }
|
||||
);
|
||||
|
||||
@@ -360,6 +360,7 @@ function AppInner() {
|
||||
onArchiveTask={archiveTask}
|
||||
onUnarchiveTask={unarchiveTask}
|
||||
onArchiveAllDone={archiveAllDone}
|
||||
onLoadArchivedTasks={loadArchivedTasks}
|
||||
searchQuery={searchQuery}
|
||||
availableModels={availableModels}
|
||||
onOpenDetailWithTab={handleOpenDetailWithTab}
|
||||
|
||||
@@ -104,12 +104,19 @@ async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function fetchTasks(limit?: number, offset?: number, projectId?: string, q?: string): Promise<Task[]> {
|
||||
export function fetchTasks(
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
projectId?: string,
|
||||
q?: string,
|
||||
includeArchived?: boolean,
|
||||
): Promise<Task[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (limit !== undefined) search.set("limit", String(limit));
|
||||
if (offset !== undefined) search.set("offset", String(offset));
|
||||
if (projectId) search.set("projectId", projectId);
|
||||
if (q) search.set("q", q);
|
||||
if (includeArchived) search.set("includeArchived", "1");
|
||||
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
||||
return api<Task[]>(`/tasks${suffix}`);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ interface BoardProps {
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onArchiveAllDone?: () => Promise<Task[]>;
|
||||
/** Lazy-load archived tasks. Called the first time the user expands the archived column. */
|
||||
onLoadArchivedTasks?: () => Promise<void>;
|
||||
searchQuery?: string;
|
||||
availableModels?: ModelInfo[];
|
||||
/**
|
||||
@@ -62,8 +64,9 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
||||
return previous.every((task, index) => task === next[index]);
|
||||
}
|
||||
|
||||
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission }: BoardProps) {
|
||||
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const archivedLoadedRef = useRef(false);
|
||||
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Normalized search-active signal: trimmed and non-empty
|
||||
@@ -78,8 +81,15 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
||||
});
|
||||
|
||||
const handleToggleArchivedCollapse = useCallback(() => {
|
||||
setArchivedCollapsed((current) => !current);
|
||||
}, []);
|
||||
setArchivedCollapsed((current) => {
|
||||
const next = !current;
|
||||
if (!next && !archivedLoadedRef.current && onLoadArchivedTasks) {
|
||||
archivedLoadedRef.current = true;
|
||||
void onLoadArchivedTasks();
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [onLoadArchivedTasks]);
|
||||
|
||||
// Tasks are already server-filtered when searchQuery is active (via useTasks hook).
|
||||
// Client-side filtering is removed - tasks prop is used directly.
|
||||
|
||||
@@ -401,7 +401,8 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("prevents default on Enter key (without Shift)", () => {
|
||||
renderQuickEntryBox({});
|
||||
const onCreate = vi.fn(() => new Promise(() => undefined));
|
||||
renderQuickEntryBox({ onCreate });
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task" } });
|
||||
@@ -497,7 +498,9 @@ describe("QuickEntryBox", () => {
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
// Wait a bit to ensure no async call happens
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
expect(props.onCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -509,7 +512,9 @@ describe("QuickEntryBox", () => {
|
||||
fireEvent.change(textarea, { target: { value: " " } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
expect(props.onCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1692,7 +1697,9 @@ describe("QuickEntryBox", () => {
|
||||
it("loading state disables button during refinement", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
// Slow down the promise to see loading state
|
||||
vi.mocked(refineText).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
vi.mocked(refineText).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve("Refined text"), 100)),
|
||||
);
|
||||
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
@@ -46,6 +46,11 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
const searchQuery = options?.searchQuery;
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [connectionNonce, setConnectionNonce] = useState(0);
|
||||
// Once the user expands the archived column, we keep including archived tasks
|
||||
// in subsequent refreshes for the lifetime of this hook instance.
|
||||
const [includeArchived, setIncludeArchived] = useState(false);
|
||||
const includeArchivedRef = useRef(includeArchived);
|
||||
includeArchivedRef.current = includeArchived;
|
||||
const tasksRef = useRef(tasks);
|
||||
const fetchVersionRef = useRef(0);
|
||||
const lastVisibilityRefreshRef = useRef<number>(0);
|
||||
@@ -56,12 +61,13 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
|
||||
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
||||
|
||||
const refreshTasks = useCallback(async (options?: { clearOnError?: boolean; searchQueryOverride?: string }) => {
|
||||
const refreshTasks = useCallback(async (options?: { clearOnError?: boolean; searchQueryOverride?: string; includeArchivedOverride?: boolean }) => {
|
||||
const requestVersion = ++fetchVersionRef.current;
|
||||
const query = options?.searchQueryOverride ?? searchQuery;
|
||||
const wantArchived = options?.includeArchivedOverride ?? includeArchivedRef.current;
|
||||
|
||||
try {
|
||||
const fetchedTasks = await api.fetchTasks(undefined, undefined, projectId, query);
|
||||
const fetchedTasks = await api.fetchTasks(undefined, undefined, projectId, query, wantArchived);
|
||||
if (fetchVersionRef.current !== requestVersion) {
|
||||
return;
|
||||
}
|
||||
@@ -79,6 +85,14 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
}, [projectId, searchQuery]);
|
||||
refreshTasksRef.current = refreshTasks;
|
||||
|
||||
/** Lazy-load archived tasks. Called by the Board when the archived column is first expanded. */
|
||||
const loadArchivedTasks = useCallback(async () => {
|
||||
if (includeArchivedRef.current) return;
|
||||
setIncludeArchived(true);
|
||||
includeArchivedRef.current = true;
|
||||
await refreshTasksRef.current({ includeArchivedOverride: true });
|
||||
}, []);
|
||||
|
||||
// Debounced search effect - separate from refreshTasks to avoid dependency cycle
|
||||
useEffect(() => {
|
||||
if (searchQuery === undefined) return;
|
||||
@@ -380,5 +394,5 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
return normalized;
|
||||
}, [projectId]);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone };
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived };
|
||||
}
|
||||
|
||||
@@ -45,6 +45,44 @@ vi.mock("@fusion/core", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createKbAgent: vi.fn(async () => ({
|
||||
session: {
|
||||
state: {
|
||||
messages: [] as Array<{ role: string; content: string }>,
|
||||
},
|
||||
prompt: vi.fn(async function (this: { state?: { messages?: Array<{ role: string; content: string }> } }, message: string) {
|
||||
const messages = this.state?.messages ?? [];
|
||||
messages.push({ role: "user", content: message });
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: JSON.stringify({
|
||||
subtasks: [
|
||||
{
|
||||
id: "subtask-1",
|
||||
title: "Mock subtask",
|
||||
description: "Generated by the route test engine mock",
|
||||
suggestedSize: "S",
|
||||
dependsOn: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
})),
|
||||
AgentReflectionService: class MockAgentReflectionService {
|
||||
async generateReflection(): Promise<never> {
|
||||
throw new Error("Reflection service unavailable in route tests");
|
||||
}
|
||||
|
||||
async buildReflectionContext(): Promise<never> {
|
||||
throw new Error("Reflection service unavailable in route tests");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { isGhAuthenticated } from "@fusion/core";
|
||||
|
||||
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
|
||||
@@ -172,7 +210,7 @@ describe("GET /tasks", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ limit: 10, offset: 5 });
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ limit: 10, offset: 5, slim: true, includeArchived: false });
|
||||
});
|
||||
|
||||
it("returns tasks for search query", async () => {
|
||||
@@ -5097,42 +5135,42 @@ describe("POST /github/issues/batch-import", () => {
|
||||
}, 60000); // Retry path can exceed 30s in CI/load-constrained environments
|
||||
|
||||
it("processes issues sequentially (not parallel)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const startedIssues: number[] = [];
|
||||
const resolvers = new Map<number, () => void>();
|
||||
|
||||
try {
|
||||
const callTimes: number[] = [];
|
||||
fetchSpy.mockImplementation(() => {
|
||||
callTimes.push(Date.now());
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockGitHubIssue(callTimes.length)),
|
||||
} as Response);
|
||||
vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockImplementation(async (url) => {
|
||||
const issueNumber = Number(String(url).split("/").pop());
|
||||
startedIssues.push(issueNumber);
|
||||
await new Promise<void>((resolve) => {
|
||||
resolvers.set(issueNumber, resolve);
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: mockGitHubIssue(issueNumber),
|
||||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>;
|
||||
});
|
||||
|
||||
// Start the request without awaiting (fake timers block real delays)
|
||||
const requestPromise = REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/github/issues/batch-import",
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 50 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
const requestPromise = REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/github/issues/batch-import",
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 50 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
// Advance fake time to resolve all sequential delays (3 issues × 50ms)
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.waitFor(() => expect(startedIssues).toEqual([1]));
|
||||
resolvers.get(1)?.();
|
||||
|
||||
await requestPromise;
|
||||
await vi.waitFor(() => expect(startedIssues).toEqual([1, 2]));
|
||||
resolvers.get(2)?.();
|
||||
|
||||
// Verify sequential processing with deterministic timing
|
||||
expect(callTimes).toHaveLength(3);
|
||||
// With fake timers, each call should be exactly 50ms apart
|
||||
for (let i = 1; i < callTimes.length; i++) {
|
||||
expect(callTimes[i] - callTimes[i - 1]).toBe(50);
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await vi.waitFor(() => expect(startedIssues).toEqual([1, 2, 3]));
|
||||
resolvers.get(3)?.();
|
||||
|
||||
const res = await requestPromise;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("requires owner parameter", async () => {
|
||||
@@ -6041,14 +6079,14 @@ describe("GET /tasks/:id/diff", () => {
|
||||
// Use a real git repo to test the commit-backed path
|
||||
const testDir = mkdtempSync(join(tmpdir(), "kb-diff-test-"));
|
||||
try {
|
||||
execFileSync("git", ["init", testDir]);
|
||||
execFileSync("git", ["-C", testDir, "config", "user.email", "test@test.com"]);
|
||||
execFileSync("git", ["-C", testDir, "config", "user.name", "Test"]);
|
||||
execFileSync("git", ["init", testDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", testDir, "config", "user.email", "test@test.com"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", testDir, "config", "user.name", "Test"], { stdio: "pipe" });
|
||||
writeFileSync(join(testDir, "a.txt"), "initial\n");
|
||||
execFileSync("git", ["-C", testDir, "add", "a.txt"]);
|
||||
execFileSync("git", ["-C", testDir, "commit", "-m", "init"]);
|
||||
execFileSync("git", ["-C", testDir, "add", "a.txt"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", testDir, "commit", "-m", "init"], { stdio: "pipe" });
|
||||
|
||||
const headSha = execFileSync("git", ["-C", testDir, "rev-parse", "HEAD"], { encoding: "utf-8" }).trim();
|
||||
const headSha = execFileSync("git", ["-C", testDir, "rev-parse", "HEAD"], { encoding: "utf-8", stdio: "pipe" }).trim();
|
||||
|
||||
const localStore = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue(testDir),
|
||||
@@ -6138,15 +6176,15 @@ describe("Git Management endpoints", () => {
|
||||
gitRepoDir = join(gitTestRoot, "repo");
|
||||
|
||||
mkdirSync(gitRepoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "--bare", remoteDir]);
|
||||
execFileSync("git", ["init", gitRepoDir]);
|
||||
execFileSync("git", ["-C", gitRepoDir, "config", "user.email", "kb-tests@example.com"]);
|
||||
execFileSync("git", ["-C", gitRepoDir, "config", "user.name", "KB Tests"]);
|
||||
execFileSync("git", ["init", "--bare", remoteDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["init", gitRepoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", gitRepoDir, "config", "user.email", "kb-tests@example.com"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", gitRepoDir, "config", "user.name", "KB Tests"], { stdio: "pipe" });
|
||||
writeFileSync(join(gitRepoDir, "README.md"), "# Test Repo\n");
|
||||
execFileSync("git", ["-C", gitRepoDir, "add", "README.md"]);
|
||||
execFileSync("git", ["-C", gitRepoDir, "commit", "-m", "Initial commit"]);
|
||||
execFileSync("git", ["-C", gitRepoDir, "remote", "add", "origin", remoteDir]);
|
||||
execFileSync("git", ["-C", gitRepoDir, "push", "-u", "origin", "HEAD"]);
|
||||
execFileSync("git", ["-C", gitRepoDir, "add", "README.md"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", gitRepoDir, "commit", "-m", "Initial commit"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", gitRepoDir, "remote", "add", "origin", remoteDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", gitRepoDir, "push", "-u", "origin", "HEAD"], { stdio: "pipe" });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -684,7 +684,7 @@ function getGitHubRemotes(cwd?: string): GitRemote[] {
|
||||
*/
|
||||
function isGitRepo(cwd?: string): boolean {
|
||||
try {
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd };
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd, stdio: "pipe" as const };
|
||||
execSync("git rev-parse --git-dir", execOptions);
|
||||
return true;
|
||||
} catch {
|
||||
@@ -810,7 +810,7 @@ function isValidGitRef(ref: string): boolean {
|
||||
function getGitCommitsForBranch(branch: string, limit: number = 10, cwd?: string): GitCommit[] {
|
||||
try {
|
||||
const format = "%H|%h|%s|%an|%aI|%P";
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd };
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd, stdio: "pipe" as const };
|
||||
const output = execSync(`git log --max-count=${limit} --pretty=format:"${format}" "${branch}"`, execOptions);
|
||||
|
||||
const commits: GitCommit[] = [];
|
||||
@@ -844,7 +844,7 @@ function getGitCommitsForBranch(branch: string, limit: number = 10, cwd?: string
|
||||
*/
|
||||
function getAheadCommits(cwd?: string): GitCommit[] {
|
||||
try {
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd };
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd, stdio: "pipe" as const };
|
||||
// Check if an upstream is configured
|
||||
try {
|
||||
execSync("git rev-parse --abbrev-ref @{u}", execOptions);
|
||||
@@ -893,7 +893,7 @@ function getRemoteCommits(remoteRef: string, limit: number = 10, cwd?: string):
|
||||
}
|
||||
|
||||
// Verify the ref exists
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd };
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd, stdio: "pipe" as const };
|
||||
try {
|
||||
execSync(`git rev-parse --verify "${remoteRef}"`, execOptions);
|
||||
} catch {
|
||||
@@ -907,6 +907,7 @@ function getRemoteCommits(remoteRef: string, limit: number = 10, cwd?: string):
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const commits: GitCommit[] = [];
|
||||
@@ -940,9 +941,9 @@ function getRemoteCommits(remoteRef: string, limit: number = 10, cwd?: string):
|
||||
*/
|
||||
function getCommitDiff(hash: string, cwd?: string): { stat: string; patch: string } | null {
|
||||
try {
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd };
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd, stdio: "pipe" as const };
|
||||
// Validate the hash is a valid git object
|
||||
execSync(`git cat-file -t ${hash}`, { encoding: "utf-8", timeout: 5000, cwd });
|
||||
execSync(`git cat-file -t ${hash}`, { encoding: "utf-8", timeout: 5000, cwd, stdio: "pipe" });
|
||||
|
||||
// Get diff stat
|
||||
const stat = execSync(`git show --stat --format="" ${hash}`, execOptions).trim();
|
||||
@@ -1122,7 +1123,7 @@ function createGitBranch(name: string, base?: string, cwd?: string): string {
|
||||
const cmd = base
|
||||
? `git checkout -b ${name} ${base}`
|
||||
: `git checkout -b ${name}`;
|
||||
execSync(cmd, { encoding: "utf-8", timeout: 10000, cwd });
|
||||
execSync(cmd, { encoding: "utf-8", timeout: 10000, cwd, stdio: "pipe" });
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -1134,7 +1135,7 @@ function checkoutGitBranch(name: string, cwd?: string): void {
|
||||
if (!isValidBranchName(name)) {
|
||||
throw new Error("Invalid branch name");
|
||||
}
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd };
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd, stdio: "pipe" as const };
|
||||
// Check for uncommitted changes that would be lost
|
||||
try {
|
||||
execSync("git diff-index --quiet HEAD --", execOptions);
|
||||
@@ -1145,7 +1146,7 @@ function checkoutGitBranch(name: string, cwd?: string): void {
|
||||
throw new Error("Uncommitted changes would be lost. Commit or stash changes first.");
|
||||
}
|
||||
}
|
||||
execSync(`git checkout ${name}`, { encoding: "utf-8", timeout: 10000, cwd });
|
||||
execSync(`git checkout ${name}`, { encoding: "utf-8", timeout: 10000, cwd, stdio: "pipe" });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1157,7 +1158,7 @@ function deleteGitBranch(name: string, force: boolean = false, cwd?: string): vo
|
||||
throw new Error("Invalid branch name");
|
||||
}
|
||||
const flag = force ? "-D" : "-d";
|
||||
execSync(`git branch ${flag} ${name}`, { encoding: "utf-8", timeout: 10000, cwd });
|
||||
execSync(`git branch ${flag} ${name}`, { encoding: "utf-8", timeout: 10000, cwd, stdio: "pipe" });
|
||||
}
|
||||
|
||||
/** Result of a fetch operation */
|
||||
@@ -1174,7 +1175,7 @@ function fetchGitRemote(remote: string = "origin", cwd?: string): GitFetchResult
|
||||
throw new Error("Invalid remote name");
|
||||
}
|
||||
try {
|
||||
const output = execSync(`git fetch ${remote}`, { encoding: "utf-8", timeout: 30000, cwd });
|
||||
const output = execSync(`git fetch ${remote}`, { encoding: "utf-8", timeout: 30000, cwd, stdio: "pipe" });
|
||||
return { fetched: true, message: output.trim() || "Fetch completed" };
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -1201,7 +1202,7 @@ export interface GitPullResult {
|
||||
*/
|
||||
function pullGitBranch(cwd?: string): GitPullResult {
|
||||
try {
|
||||
const output = execSync("git pull", { encoding: "utf-8", timeout: 30000, cwd });
|
||||
const output = execSync("git pull", { encoding: "utf-8", timeout: 30000, cwd, stdio: "pipe" });
|
||||
return { success: true, message: output.trim() };
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -1227,7 +1228,7 @@ export interface GitPushResult {
|
||||
*/
|
||||
function pushGitBranch(cwd?: string): GitPushResult {
|
||||
try {
|
||||
const output = execSync("git push", { encoding: "utf-8", timeout: 30000, cwd });
|
||||
const output = execSync("git push", { encoding: "utf-8", timeout: 30000, cwd, stdio: "pipe" });
|
||||
return { success: true, message: output.trim() || "Push completed" };
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -1326,7 +1327,7 @@ function addGitRemote(name: string, url: string, cwd?: string): void {
|
||||
throw new Error("Invalid git URL format");
|
||||
}
|
||||
try {
|
||||
execSync(`git remote add ${name} ${url}`, { encoding: "utf-8", timeout: 10000, cwd });
|
||||
execSync(`git remote add ${name} ${url}`, { encoding: "utf-8", timeout: 10000, cwd, stdio: "pipe" });
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
@@ -1347,7 +1348,7 @@ function removeGitRemote(name: string, cwd?: string): void {
|
||||
throw new Error("Invalid remote name");
|
||||
}
|
||||
try {
|
||||
execSync(`git remote remove ${name}`, { encoding: "utf-8", timeout: 10000, cwd });
|
||||
execSync(`git remote remove ${name}`, { encoding: "utf-8", timeout: 10000, cwd, stdio: "pipe" });
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
@@ -1371,7 +1372,7 @@ function renameGitRemote(oldName: string, newName: string, cwd?: string): void {
|
||||
throw new Error("Invalid new remote name");
|
||||
}
|
||||
try {
|
||||
execSync(`git remote rename ${oldName} ${newName}`, { encoding: "utf-8", timeout: 10000, cwd });
|
||||
execSync(`git remote rename ${oldName} ${newName}`, { encoding: "utf-8", timeout: 10000, cwd, stdio: "pipe" });
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
@@ -1398,7 +1399,7 @@ function setGitRemoteUrl(name: string, url: string, cwd?: string): void {
|
||||
throw new Error("Invalid git URL format");
|
||||
}
|
||||
try {
|
||||
execSync(`git remote set-url ${name} ${url}`, { encoding: "utf-8", timeout: 10000, cwd });
|
||||
execSync(`git remote set-url ${name} ${url}`, { encoding: "utf-8", timeout: 10000, cwd, stdio: "pipe" });
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
@@ -2268,6 +2269,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const limit = typeof req.query.limit === "string" ? Number.parseInt(req.query.limit, 10) : undefined;
|
||||
const offset = typeof req.query.offset === "string" ? Number.parseInt(req.query.offset, 10) : undefined;
|
||||
const q = typeof req.query.q === "string" ? req.query.q.trim() : undefined;
|
||||
const includeArchived = req.query.includeArchived === "1" || req.query.includeArchived === "true";
|
||||
|
||||
if (limit !== undefined && (!Number.isFinite(limit) || limit < 0)) {
|
||||
throw badRequest("limit must be a non-negative integer");
|
||||
@@ -2281,7 +2283,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
if (q && q.length > 0) {
|
||||
tasks = await scopedStore.searchTasks(q, { limit, offset });
|
||||
} else {
|
||||
tasks = await scopedStore.listTasks({ limit, offset });
|
||||
// Board-view list: omit heavy fields (log/comments/steps/workflowStepResults) and
|
||||
// exclude 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.
|
||||
tasks = await scopedStore.listTasks({ limit, offset, slim: true, includeArchived });
|
||||
}
|
||||
res.json(tasks);
|
||||
} catch (err: any) {
|
||||
@@ -2852,7 +2857,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
try {
|
||||
return nodeChildProcess.execSync(
|
||||
`git merge-base HEAD origin/${baseBranch} 2>/dev/null || git merge-base HEAD ${baseBranch}`,
|
||||
{ cwd, encoding: "utf-8", timeout: 5000 },
|
||||
{ cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" },
|
||||
).trim() || undefined;
|
||||
} catch {
|
||||
// merge-base unavailable — fall through to HEAD~1
|
||||
@@ -3894,6 +3899,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
}).trim();
|
||||
// symbolic-ref returns full ref like refs/remotes/origin/main
|
||||
remoteRef = headRef.replace(/^refs\/remotes\//, "");
|
||||
@@ -3904,6 +3910,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
remoteRef = `${name}/main`;
|
||||
} catch {
|
||||
@@ -3912,6 +3919,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
remoteRef = `${name}/master`;
|
||||
} catch {
|
||||
@@ -12916,7 +12924,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
try {
|
||||
mergeBase = nodeChildProcess.execSync(
|
||||
`git rev-parse ${sha}^`,
|
||||
{ cwd: rootDir, encoding: "utf-8", timeout: 5000 },
|
||||
{ cwd: rootDir, encoding: "utf-8", timeout: 5000, stdio: "pipe" },
|
||||
).trim();
|
||||
} catch {
|
||||
// Last resort: no diff available
|
||||
@@ -12926,7 +12934,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
const nameStatus = nodeChildProcess.execSync(
|
||||
`git diff --name-status ${mergeBase}..${sha}`,
|
||||
{ cwd: rootDir, encoding: "utf-8", timeout: 10000 },
|
||||
{ cwd: rootDir, encoding: "utf-8", timeout: 10000, stdio: "pipe" },
|
||||
).trim();
|
||||
|
||||
const doneFiles: Array<{
|
||||
@@ -13098,7 +13106,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
try {
|
||||
mergeBase = nodeChildProcess.execSync(
|
||||
`git rev-parse ${sha}^`,
|
||||
{ cwd: rootDir, encoding: "utf-8", timeout: 5000 },
|
||||
{ cwd: rootDir, encoding: "utf-8", timeout: 5000, stdio: "pipe" },
|
||||
).trim();
|
||||
} catch {
|
||||
res.json([]);
|
||||
@@ -13108,7 +13116,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
try {
|
||||
const nameStatus = nodeChildProcess.execSync(
|
||||
`git diff --name-status ${mergeBase}..${sha}`,
|
||||
{ cwd: rootDir, encoding: "utf-8", timeout: 5000 },
|
||||
{ cwd: rootDir, encoding: "utf-8", timeout: 5000, stdio: "pipe" },
|
||||
).trim();
|
||||
const doneFiles = nameStatus.split("\n").filter(Boolean).map((line) => {
|
||||
const parts = line.split("\t");
|
||||
|
||||
@@ -1,6 +1,55 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
|
||||
const noisyOutputMarkers = [
|
||||
"Subagent result watcher failed",
|
||||
"pi-async-subagent-results",
|
||||
"[pi] createKbAgent called",
|
||||
"[pi] Session created successfully",
|
||||
"[pi-claude-cli] Claude CLI is not authenticated",
|
||||
"Terminal WebSocket server mounted at /api/terminal/ws",
|
||||
"[api:error]",
|
||||
"[models] Failed to load models:",
|
||||
"[routes] failed to trigger",
|
||||
];
|
||||
|
||||
function isNoisyTestOutput(value: unknown): boolean {
|
||||
const text = typeof value === "string" || value instanceof Buffer ? String(value) : "";
|
||||
return noisyOutputMarkers.some((marker) => text.includes(marker));
|
||||
}
|
||||
|
||||
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = ((chunk: unknown, ...args: unknown[]) => {
|
||||
if (isNoisyTestOutput(chunk)) {
|
||||
return true;
|
||||
}
|
||||
return originalStdoutWrite(chunk as any, ...(args as any));
|
||||
}) as typeof process.stdout.write;
|
||||
|
||||
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = ((chunk: unknown, ...args: unknown[]) => {
|
||||
if (isNoisyTestOutput(chunk)) {
|
||||
return true;
|
||||
}
|
||||
return originalStderrWrite(chunk as any, ...(args as any));
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
const originalConsoleLog = console.log.bind(console);
|
||||
console.log = (...args: unknown[]) => {
|
||||
if (args.some(isNoisyTestOutput)) {
|
||||
return;
|
||||
}
|
||||
originalConsoleLog(...args);
|
||||
};
|
||||
|
||||
const originalConsoleError = console.error.bind(console);
|
||||
console.error = (...args: unknown[]) => {
|
||||
if (args.some(isNoisyTestOutput)) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
};
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock: Record<string, string> = {};
|
||||
if (typeof window !== "undefined") {
|
||||
|
||||
@@ -344,6 +344,7 @@ export class SelfHealingManager {
|
||||
await this.recoverMisclassifiedFailures();
|
||||
await this.recoverOrphanedExecutions();
|
||||
await this.recoverApprovedTriageTasks();
|
||||
await this.archiveStaleDoneTasks();
|
||||
|
||||
const elapsedMs = Date.now() - startMs;
|
||||
log.log(`Maintenance cycle completed in ${elapsedMs}ms`);
|
||||
@@ -352,6 +353,55 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-archive of stale done tasks ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Auto-archive done tasks older than 48 hours so the dashboard board view
|
||||
* stops accumulating thousands of completed tasks. Data remains in SQLite —
|
||||
* the task is moved from `done` to `archived`, which the slim list endpoint
|
||||
* excludes by default. Users can still expand the archived column or unarchive.
|
||||
*/
|
||||
private static readonly AUTO_ARCHIVE_AFTER_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
async archiveStaleDoneTasks(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks();
|
||||
const cutoff = Date.now() - SelfHealingManager.AUTO_ARCHIVE_AFTER_MS;
|
||||
|
||||
const stale = tasks.filter((t) => {
|
||||
if (t.column !== "done") return false;
|
||||
// Prefer columnMovedAt (when the task entered done); fall back to updatedAt
|
||||
// for legacy tasks that lack the field.
|
||||
const ts = t.columnMovedAt || t.updatedAt;
|
||||
const movedAt = ts ? Date.parse(ts) : NaN;
|
||||
if (!Number.isFinite(movedAt)) return false;
|
||||
return movedAt < cutoff;
|
||||
});
|
||||
|
||||
if (stale.length === 0) return 0;
|
||||
|
||||
log.log(`Auto-archiving ${stale.length} done task(s) older than 48h`);
|
||||
|
||||
let archived = 0;
|
||||
for (const task of stale) {
|
||||
try {
|
||||
await this.store.archiveTask(task.id);
|
||||
archived++;
|
||||
} catch (err: any) {
|
||||
log.error(`Failed to auto-archive ${task.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (archived > 0) {
|
||||
log.log(`Auto-archived ${archived} stale done task(s)`);
|
||||
}
|
||||
return archived;
|
||||
} catch (err: any) {
|
||||
log.error(`Auto-archive sweep failed: ${err.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Completed task recovery ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user