feat(FN-684): complete Step 1 — add ExecutorStats type and API function
This commit is contained in:
@@ -34,11 +34,14 @@ import {
|
|||||||
fetchGlobalConcurrency,
|
fetchGlobalConcurrency,
|
||||||
fetchProjectTasks,
|
fetchProjectTasks,
|
||||||
fetchProjectConfig,
|
fetchProjectConfig,
|
||||||
|
fetchExecutorStats,
|
||||||
type ProjectInfo,
|
type ProjectInfo,
|
||||||
type ProjectHealth,
|
type ProjectHealth,
|
||||||
type ActivityFeedEntry,
|
type ActivityFeedEntry,
|
||||||
type FirstRunStatus,
|
type FirstRunStatus,
|
||||||
type GlobalConcurrencyState,
|
type GlobalConcurrencyState,
|
||||||
|
type ExecutorStats,
|
||||||
|
type ExecutorState,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import type { Task, TaskDetail, BatchStatusResponse } from "@fusion/core";
|
import type { Task, TaskDetail, BatchStatusResponse } from "@fusion/core";
|
||||||
|
|
||||||
@@ -2159,3 +2162,151 @@ describe("fetchProjectConfig", () => {
|
|||||||
expect(result.rootDir).toBe("/path/to/project");
|
expect(result.rootDir).toBe("/path/to/project");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fetchExecutorStats", () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns executor stats with running state", async () => {
|
||||||
|
const response = {
|
||||||
|
globalPause: false,
|
||||||
|
enginePaused: false,
|
||||||
|
maxConcurrent: 4,
|
||||||
|
lastActivityAt: "2026-04-01T12:00:00.000Z",
|
||||||
|
};
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, response));
|
||||||
|
|
||||||
|
const result = await fetchExecutorStats();
|
||||||
|
|
||||||
|
expect(result.globalPause).toBe(false);
|
||||||
|
expect(result.enginePaused).toBe(false);
|
||||||
|
expect(result.maxConcurrent).toBe(4);
|
||||||
|
expect(result.lastActivityAt).toBe("2026-04-01T12:00:00.000Z");
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith("/api/executor/stats", {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns executor stats with paused state", async () => {
|
||||||
|
const response = {
|
||||||
|
globalPause: false,
|
||||||
|
enginePaused: true,
|
||||||
|
maxConcurrent: 2,
|
||||||
|
lastActivityAt: "2026-04-01T11:00:00.000Z",
|
||||||
|
};
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, response));
|
||||||
|
|
||||||
|
const result = await fetchExecutorStats();
|
||||||
|
|
||||||
|
expect(result.globalPause).toBe(false);
|
||||||
|
expect(result.enginePaused).toBe(true);
|
||||||
|
expect(result.maxConcurrent).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns executor stats with global pause", async () => {
|
||||||
|
const response = {
|
||||||
|
globalPause: true,
|
||||||
|
enginePaused: false,
|
||||||
|
maxConcurrent: 2,
|
||||||
|
};
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, response));
|
||||||
|
|
||||||
|
const result = await fetchExecutorStats();
|
||||||
|
|
||||||
|
expect(result.globalPause).toBe(true);
|
||||||
|
expect(result.enginePaused).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on API error", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Internal server error" }));
|
||||||
|
|
||||||
|
await expect(fetchExecutorStats()).rejects.toThrow("Internal server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ExecutorStats type", () => {
|
||||||
|
it("has correct shape for executor stats object", () => {
|
||||||
|
const stats: ExecutorStats = {
|
||||||
|
runningTaskCount: 3,
|
||||||
|
blockedTaskCount: 2,
|
||||||
|
stuckTaskCount: 1,
|
||||||
|
queuedTaskCount: 10,
|
||||||
|
inReviewCount: 4,
|
||||||
|
executorState: "running",
|
||||||
|
maxConcurrent: 4,
|
||||||
|
lastActivityAt: "2026-04-01T12:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(stats.runningTaskCount).toBe(3);
|
||||||
|
expect(stats.blockedTaskCount).toBe(2);
|
||||||
|
expect(stats.stuckTaskCount).toBe(1);
|
||||||
|
expect(stats.queuedTaskCount).toBe(10);
|
||||||
|
expect(stats.inReviewCount).toBe(4);
|
||||||
|
expect(stats.executorState).toBe("running");
|
||||||
|
expect(stats.maxConcurrent).toBe(4);
|
||||||
|
expect(stats.lastActivityAt).toBe("2026-04-01T12:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts all valid executor states", () => {
|
||||||
|
const idleStats: ExecutorStats = {
|
||||||
|
runningTaskCount: 0,
|
||||||
|
blockedTaskCount: 0,
|
||||||
|
stuckTaskCount: 0,
|
||||||
|
queuedTaskCount: 5,
|
||||||
|
inReviewCount: 0,
|
||||||
|
executorState: "idle",
|
||||||
|
maxConcurrent: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const runningStats: ExecutorStats = {
|
||||||
|
runningTaskCount: 2,
|
||||||
|
blockedTaskCount: 1,
|
||||||
|
stuckTaskCount: 0,
|
||||||
|
queuedTaskCount: 3,
|
||||||
|
inReviewCount: 1,
|
||||||
|
executorState: "running",
|
||||||
|
maxConcurrent: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pausedStats: ExecutorStats = {
|
||||||
|
runningTaskCount: 1,
|
||||||
|
blockedTaskCount: 0,
|
||||||
|
stuckTaskCount: 0,
|
||||||
|
queuedTaskCount: 8,
|
||||||
|
inReviewCount: 2,
|
||||||
|
executorState: "paused",
|
||||||
|
maxConcurrent: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(idleStats.executorState).toBe("idle");
|
||||||
|
expect(runningStats.executorState).toBe("running");
|
||||||
|
expect(pausedStats.executorState).toBe("paused");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows optional lastActivityAt", () => {
|
||||||
|
const stats: ExecutorStats = {
|
||||||
|
runningTaskCount: 0,
|
||||||
|
blockedTaskCount: 0,
|
||||||
|
stuckTaskCount: 0,
|
||||||
|
queuedTaskCount: 0,
|
||||||
|
inReviewCount: 0,
|
||||||
|
executorState: "idle",
|
||||||
|
maxConcurrent: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(stats.lastActivityAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ExecutorState type", () => {
|
||||||
|
it("has valid executor state values", () => {
|
||||||
|
const states: ExecutorState[] = ["idle", "running", "paused"];
|
||||||
|
|
||||||
|
expect(states).toContain("idle");
|
||||||
|
expect(states).toContain("running");
|
||||||
|
expect(states).toContain("paused");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1799,6 +1799,40 @@ export interface ProjectHealth {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Executor state values */
|
||||||
|
export type ExecutorState = "idle" | "running" | "paused";
|
||||||
|
|
||||||
|
/** Aggregated executor statistics for the status bar.
|
||||||
|
*
|
||||||
|
* Counts (runningTaskCount, blockedTaskCount, queuedTaskCount, inReviewCount, stuckTaskCount)
|
||||||
|
* are derived client-side from the tasks array to avoid duplication.
|
||||||
|
* The API returns settings-based values (globalPause, enginePaused, maxConcurrent) and
|
||||||
|
* lastActivityAt from the activity log.
|
||||||
|
*
|
||||||
|
* The executorState is derived from:
|
||||||
|
* - "idle": globalPause is true OR (enginePaused is true AND runningTaskCount is 0)
|
||||||
|
* - "paused": enginePaused is true AND runningTaskCount > 0
|
||||||
|
* - "running": globalPause is false AND enginePaused is false AND runningTaskCount > 0
|
||||||
|
*/
|
||||||
|
export interface ExecutorStats {
|
||||||
|
/** Number of tasks currently in "in-progress" column */
|
||||||
|
runningTaskCount: number;
|
||||||
|
/** Number of tasks with blockedBy field set (waiting on file overlap) */
|
||||||
|
blockedTaskCount: number;
|
||||||
|
/** Number of "in-progress" tasks with no activity for > 10 minutes */
|
||||||
|
stuckTaskCount: number;
|
||||||
|
/** Number of tasks in "todo" column */
|
||||||
|
queuedTaskCount: number;
|
||||||
|
/** Number of tasks in "in-review" column */
|
||||||
|
inReviewCount: number;
|
||||||
|
/** Derived executor state: "idle", "running", or "paused" */
|
||||||
|
executorState: ExecutorState;
|
||||||
|
/** Maximum concurrent tasks allowed from settings */
|
||||||
|
maxConcurrent: number;
|
||||||
|
/** ISO timestamp of most recent task event from activity log */
|
||||||
|
lastActivityAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Unified activity feed entry */
|
/** Unified activity feed entry */
|
||||||
export interface ActivityFeedEntry {
|
export interface ActivityFeedEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1902,6 +1936,25 @@ export function fetchProjectHealth(id: string): Promise<ProjectHealth> {
|
|||||||
return api<ProjectHealth>(`/projects/${encodeURIComponent(id)}/health`);
|
return api<ProjectHealth>(`/projects/${encodeURIComponent(id)}/health`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch executor statistics for the status bar.
|
||||||
|
*
|
||||||
|
* Returns settings-based values and lastActivityAt.
|
||||||
|
* Counts are derived client-side from the tasks array.
|
||||||
|
*/
|
||||||
|
export function fetchExecutorStats(): Promise<{
|
||||||
|
globalPause: boolean;
|
||||||
|
enginePaused: boolean;
|
||||||
|
maxConcurrent: number;
|
||||||
|
lastActivityAt?: string;
|
||||||
|
}> {
|
||||||
|
return api<{
|
||||||
|
globalPause: boolean;
|
||||||
|
enginePaused: boolean;
|
||||||
|
maxConcurrent: number;
|
||||||
|
lastActivityAt?: string;
|
||||||
|
}>("/executor/stats");
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch unified activity feed */
|
/** Fetch unified activity feed */
|
||||||
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
|
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
Reference in New Issue
Block a user