feat(FN-4360): complete Step 2 — add reliability health endpoint
Fusion-Task-Id: FN-4360 Fusion-Task-Lineage: 50e17cef-29b0-4adc-aa1c-bffdebe4b43c
This commit is contained in:
@@ -480,6 +480,131 @@ describe("createServer health and headless mode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns reliability metrics payload from /api/health/reliability", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-13T12:00:00.000Z"));
|
||||
|
||||
const activityLog = [
|
||||
{ id: "e1", timestamp: "2026-05-13T10:00:00.000Z", type: "task:moved", taskId: "FN-1", details: "", metadata: { from: "todo", to: "in-review" } },
|
||||
{ id: "e2", timestamp: "2026-05-13T11:00:00.000Z", type: "task:moved", taskId: "FN-1", details: "", metadata: { from: "in-review", to: "in-progress" } },
|
||||
{ id: "m1", timestamp: "2026-05-13T11:30:00.000Z", type: "task:merged", taskId: "FN-1", details: "" },
|
||||
];
|
||||
const runAuditEvents = [
|
||||
{
|
||||
id: "ra1",
|
||||
timestamp: "2026-05-13T11:20:00.000Z",
|
||||
taskId: "FN-1",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
domain: "git",
|
||||
mutationType: "merge:start",
|
||||
target: "FN-1",
|
||||
metadata: { phase: "merge-attempt-1" },
|
||||
},
|
||||
];
|
||||
|
||||
const store = createMockStore({
|
||||
getActivityLog: vi.fn().mockResolvedValue(activityLog),
|
||||
getRunAuditEvents: vi.fn().mockReturnValue(runAuditEvents),
|
||||
});
|
||||
|
||||
const app = createServer(store);
|
||||
const res = await GET(app, "/api/health/reliability");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
windowDays: 7,
|
||||
generatedAt: expect.any(String),
|
||||
headline: { inReviewFailureRate7d: 1 },
|
||||
perDay: expect.any(Array),
|
||||
duration: { p50Ms: null, p95Ms: null, sampleCount: 0, reason: "insufficient-samples" },
|
||||
mergeAttempts: { mean: 1, max: 1, histogram: { "1": 1 } },
|
||||
});
|
||||
expect((store.getRunAuditEvents as any).mock.calls[0][0]).toMatchObject({ limit: 50_000 });
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("applies windowDays filter to activity and run-audit query windows", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-13T12:00:00.000Z"));
|
||||
|
||||
const store = createMockStore({
|
||||
getActivityLog: vi.fn().mockResolvedValue([]),
|
||||
getRunAuditEvents: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
const app = createServer(store);
|
||||
|
||||
const res = await GET(app, "/api/health/reliability?windowDays=3");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.getActivityLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
since: "2026-05-10T12:00:00.000Z",
|
||||
limit: 50_000,
|
||||
}),
|
||||
);
|
||||
expect(store.getRunAuditEvents).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
startTime: "2026-05-10T12:00:00.000Z",
|
||||
endTime: "2026-05-13T12:00:00.000Z",
|
||||
limit: 50_000,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("computes headline inReviewFailureRate7d correctly", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-13T12:00:00.000Z"));
|
||||
|
||||
const activityLog = Array.from({ length: 10 }, (_, index) => ({
|
||||
id: `entered-${index}`,
|
||||
timestamp: "2026-05-13T10:00:00.000Z",
|
||||
type: "task:moved",
|
||||
taskId: `FN-${index}`,
|
||||
details: "",
|
||||
metadata: { from: "todo", to: "in-review" },
|
||||
})).concat(
|
||||
Array.from({ length: 2 }, (_, index) => ({
|
||||
id: `bounce-${index}`,
|
||||
timestamp: "2026-05-13T10:05:00.000Z",
|
||||
type: "task:moved",
|
||||
taskId: `FN-${index}`,
|
||||
details: "",
|
||||
metadata: { from: "in-review", to: "in-progress" },
|
||||
})),
|
||||
);
|
||||
|
||||
const store = createMockStore({
|
||||
getActivityLog: vi.fn().mockResolvedValue(activityLog),
|
||||
getRunAuditEvents: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
const app = createServer(store);
|
||||
const res = await GET(app, "/api/health/reliability");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.headline).toEqual({ inReviewFailureRate7d: 0.2 });
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("rejects invalid windowDays values", async () => {
|
||||
const app = createServer(createMockStore({
|
||||
getActivityLog: vi.fn().mockResolvedValue([]),
|
||||
getRunAuditEvents: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
const zero = await GET(app, "/api/health/reliability?windowDays=0");
|
||||
const high = await GET(app, "/api/health/reliability?windowDays=31");
|
||||
|
||||
expect(zero.status).toBe(400);
|
||||
expect(high.status).toBe(400);
|
||||
expect(zero.body.error).toBe("Invalid windowDays");
|
||||
});
|
||||
|
||||
it("surfaces startup-detected nextSequence collisions end-to-end through /api/health", async () => {
|
||||
const rootDir = makeTmpDir();
|
||||
const globalDir = makeTmpDir();
|
||||
|
||||
@@ -60,6 +60,16 @@ import type { SkillsAdapter } from "./skills-adapter.js";
|
||||
import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js";
|
||||
import { validateRemoteAuthToken } from "./remote-auth.js";
|
||||
import { getCliPackageVersion } from "./cli-package-version.js";
|
||||
import {
|
||||
fileScopeInvariantFailuresPerDay,
|
||||
inReviewDurationMetrics,
|
||||
inReviewFailureRate7d,
|
||||
mergeAttemptsPerMergedTask,
|
||||
postMergeAuditFailuresPerDay,
|
||||
recoverAlreadyMergedReviewTasksRecoveriesPerDay,
|
||||
tasksBouncedToInProgressPerDay,
|
||||
tasksEnteredInReviewPerDay,
|
||||
} from "./reliability-metrics.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -1099,6 +1109,87 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
res.json(buildHealthPayload(store, cliPackageVersion));
|
||||
});
|
||||
|
||||
app.get("/api/health/reliability", async (req, res) => {
|
||||
const rawWindowDays = req.query.windowDays;
|
||||
const parsedWindowDays = rawWindowDays === undefined ? 7 : Number.parseInt(String(rawWindowDays), 10);
|
||||
|
||||
if (!Number.isInteger(parsedWindowDays) || parsedWindowDays < 1 || parsedWindowDays > 30) {
|
||||
res.status(400).json({
|
||||
error: "Invalid windowDays",
|
||||
message: "windowDays must be an integer between 1 and 30",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
const windowStartMs = nowMs - parsedWindowDays * 86_400_000;
|
||||
const startIso = new Date(windowStartMs).toISOString();
|
||||
const endIso = new Date(nowMs).toISOString();
|
||||
|
||||
const [runAuditEvents, activityLog] = await Promise.all([
|
||||
Promise.resolve(store.getRunAuditEvents({ startTime: startIso, endTime: endIso, limit: 50_000 })),
|
||||
store.getActivityLog({ since: startIso, limit: 50_000 }),
|
||||
]);
|
||||
|
||||
const enteredByDay = tasksEnteredInReviewPerDay(activityLog, windowStartMs, nowMs);
|
||||
const bouncedByDay = tasksBouncedToInProgressPerDay(activityLog, windowStartMs, nowMs);
|
||||
const postMergeByDay = postMergeAuditFailuresPerDay(runAuditEvents, windowStartMs, nowMs);
|
||||
const fileScopeByDay = fileScopeInvariantFailuresPerDay(runAuditEvents, windowStartMs, nowMs);
|
||||
const recoveriesByDay = recoverAlreadyMergedReviewTasksRecoveriesPerDay(runAuditEvents, windowStartMs, nowMs);
|
||||
const duration = inReviewDurationMetrics(activityLog, windowStartMs, nowMs);
|
||||
const mergeAttempts = mergeAttemptsPerMergedTask(runAuditEvents, activityLog, windowStartMs, nowMs);
|
||||
const headline = inReviewFailureRate7d(enteredByDay, bouncedByDay, nowMs);
|
||||
|
||||
const perDay: Array<{
|
||||
date: string;
|
||||
tasksEnteredInReview: number;
|
||||
tasksBouncedToInProgress: number;
|
||||
postMergeAuditFailures: { block: number; warn: number; off: number } | null;
|
||||
fileScopeInvariantFailures: number | null;
|
||||
recoverAlreadyMergedReviewTasksRecoveries: number | null;
|
||||
}> = [];
|
||||
|
||||
const dayCursor = new Date(startIso);
|
||||
const dayEnd = new Date(endIso);
|
||||
dayCursor.setUTCHours(0, 0, 0, 0);
|
||||
dayEnd.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (dayCursor.getTime() <= dayEnd.getTime()) {
|
||||
const day = dayCursor.toISOString().slice(0, 10);
|
||||
perDay.push({
|
||||
date: day,
|
||||
tasksEnteredInReview: enteredByDay[day] ?? 0,
|
||||
tasksBouncedToInProgress: bouncedByDay[day] ?? 0,
|
||||
postMergeAuditFailures: postMergeByDay.value ? (postMergeByDay.value[day] ?? { block: 0, warn: 0, off: 0 }) : null,
|
||||
fileScopeInvariantFailures: fileScopeByDay.value ? (fileScopeByDay.value[day] ?? 0) : null,
|
||||
recoverAlreadyMergedReviewTasksRecoveries: recoveriesByDay.value ? (recoveriesByDay.value[day] ?? 0) : null,
|
||||
});
|
||||
dayCursor.setUTCDate(dayCursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
res.json({
|
||||
windowDays: parsedWindowDays,
|
||||
generatedAt: new Date(nowMs).toISOString(),
|
||||
headline: {
|
||||
inReviewFailureRate7d: headline.value,
|
||||
...(headline.reason ? { reason: headline.reason } : {}),
|
||||
},
|
||||
perDay,
|
||||
duration: {
|
||||
p50Ms: duration.p50Ms,
|
||||
p95Ms: duration.p95Ms,
|
||||
sampleCount: duration.sampleCount,
|
||||
...(duration.reason ? { reason: duration.reason } : {}),
|
||||
},
|
||||
mergeAttempts: {
|
||||
mean: mergeAttempts.mean,
|
||||
max: mergeAttempts.max,
|
||||
histogram: mergeAttempts.histogram,
|
||||
...(mergeAttempts.reason ? { reason: mergeAttempts.reason } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/health/refresh", (_req, res) => {
|
||||
const report = store.refreshTaskIdIntegrityReport();
|
||||
const database = store.getDatabaseHealth();
|
||||
|
||||
Reference in New Issue
Block a user