test(FN-4663): add stranded refinement route and store coverage
Fusion-Task-Id: FN-4663 Fusion-Task-Lineage: 80975e9f-51e2-42ee-9f61-5e065a3fed24
This commit is contained in:
committed by
gsxdsm
parent
52b167879b
commit
2626f61708
74
packages/core/src/__tests__/stranded-refinements.test.ts
Normal file
74
packages/core/src/__tests__/stranded-refinements.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { TaskStore } from "../store.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-stranded-refinements-"));
|
||||
}
|
||||
|
||||
describe("TaskStore.listStrandedRefinements", () => {
|
||||
let rootDir: string;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = makeTmpDir();
|
||||
store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"), { inMemoryDb: true });
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
store.stopWatching();
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("classifies stranded refinement reasons and excludes fresh/paused/non-triage", async () => {
|
||||
const createRefinement = async (label: string) => {
|
||||
const source = await store.createTask({ description: `source-${label}`, column: "done" });
|
||||
return store.refineTask(source.id, `refine-${label}`);
|
||||
};
|
||||
|
||||
const stale = await createRefinement("stale");
|
||||
const awaiting = await createRefinement("awaiting");
|
||||
const failed = await createRefinement("failed");
|
||||
const stuck = await createRefinement("stuck");
|
||||
const backoff = await createRefinement("backoff");
|
||||
const paused = await createRefinement("paused");
|
||||
const fresh = await createRefinement("fresh");
|
||||
const nonTriage = await createRefinement("todo");
|
||||
|
||||
await store.updateTask(awaiting.id, { status: "awaiting-approval" });
|
||||
await store.updateTask(failed.id, { status: "failed" });
|
||||
await store.updateTask(stuck.id, { status: "stuck-killed" });
|
||||
await store.updateTask(backoff.id, { nextRecoveryAt: new Date(Date.now() + 60_000).toISOString() });
|
||||
await store.updateTask(paused.id, { paused: true });
|
||||
await store.moveTask(nonTriage.id, "todo");
|
||||
|
||||
const db = store.getDatabase();
|
||||
db.prepare('UPDATE tasks SET createdAt = ?, updatedAt = ? WHERE id = ?').run(
|
||||
new Date(Date.now() - 11 * 60_000).toISOString(),
|
||||
new Date().toISOString(),
|
||||
stale.id,
|
||||
);
|
||||
|
||||
const list = await store.listStrandedRefinements({ freshnessThresholdMs: 10 * 60 * 1000 });
|
||||
const byId = new Map(list.map((entry) => [entry.task.id, entry.reasons]));
|
||||
|
||||
expect(byId.get(stale.id)).toContain("untriaged-stale");
|
||||
expect(byId.get(awaiting.id)).toContain("awaiting-approval");
|
||||
expect(byId.get(failed.id)).toContain("failed");
|
||||
expect(byId.get(stuck.id)).toContain("stuck-killed");
|
||||
expect(byId.get(backoff.id)).toContain("recovery-backoff");
|
||||
expect(byId.has(paused.id)).toBe(false);
|
||||
expect(byId.has(fresh.id)).toBe(false);
|
||||
expect(byId.has(nonTriage.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty when no refinement tasks are stranded", async () => {
|
||||
await store.createTask({ description: "normal task" });
|
||||
const list = await store.listStrandedRefinements();
|
||||
expect(list).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import express from "express";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { TaskStore, TaskDetail } from "@fusion/core";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
getRootDir: vi.fn().mockReturnValue(mkdtempSync(join(tmpdir(), "kb-stranded-route-"))),
|
||||
listStrandedRefinements: vi.fn().mockResolvedValue([]),
|
||||
getTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function createApp(store: TaskStore) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
async function REQUEST(app: express.Express, method: string, path: string) {
|
||||
return performRequest(app, method, path);
|
||||
}
|
||||
|
||||
const BASE_TASK: TaskDetail = {
|
||||
id: "FN-100",
|
||||
title: "refine",
|
||||
description: "refine",
|
||||
column: "triage",
|
||||
sourceType: "task_refine",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date(Date.now() - 11 * 60_000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
prompt: "",
|
||||
};
|
||||
|
||||
describe("stranded refinement routes", () => {
|
||||
it("GET /tasks/stranded-refinements returns list", async () => {
|
||||
const store = createMockStore({
|
||||
listStrandedRefinements: vi.fn().mockResolvedValue([{ task: BASE_TASK, reasons: ["untriaged-stale"], ageMs: 1000 }]),
|
||||
});
|
||||
const res = await REQUEST(createApp(store), "GET", "/api/tasks/stranded-refinements");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("GET /tasks/stranded-refinements rejects invalid freshnessMinutes", async () => {
|
||||
const store = createMockStore();
|
||||
const res = await REQUEST(createApp(store), "GET", "/api/tasks/stranded-refinements?freshnessMinutes=0");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /tasks/:id/expedite-refinement keeps awaiting-approval status", async () => {
|
||||
const task = { ...BASE_TASK, status: "awaiting-approval" as const };
|
||||
const store = createMockStore({ getTask: vi.fn().mockResolvedValue(task) });
|
||||
const res = await REQUEST(createApp(store), "POST", "/api/tasks/FN-100/expedite-refinement");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.expedited).toBe(false);
|
||||
expect(res.body.requiresOperatorAction).toBe("approve-plan");
|
||||
expect((store.updateTask as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("POST /tasks/:id/expedite-refinement clears recovery backoff", async () => {
|
||||
const task = { ...BASE_TASK, nextRecoveryAt: new Date(Date.now() + 60_000).toISOString() };
|
||||
const updated = { ...task, nextRecoveryAt: undefined };
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
listStrandedRefinements: vi.fn().mockResolvedValue([{ task, reasons: ["recovery-backoff"], ageMs: 1000 }]),
|
||||
updateTask: vi.fn().mockResolvedValue(updated),
|
||||
});
|
||||
const res = await REQUEST(createApp(store), "POST", "/api/tasks/FN-100/expedite-refinement");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.expedited).toBe(true);
|
||||
expect(res.body.task.column).toBe("triage");
|
||||
});
|
||||
|
||||
it("GET /tasks/:id/stranded-refinement returns detail", async () => {
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "kb-stranded-detail-"));
|
||||
mkdirSync(join(rootDir, ".fusion", "tasks", "FN-100"), { recursive: true });
|
||||
writeFileSync(join(rootDir, ".fusion", "tasks", "FN-100", "PROMPT.md"), "# prompt\n");
|
||||
const store = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue(rootDir),
|
||||
getTask: vi.fn().mockResolvedValue(BASE_TASK),
|
||||
listStrandedRefinements: vi.fn().mockResolvedValue([{ task: BASE_TASK, reasons: ["untriaged-stale"], ageMs: 1000 }]),
|
||||
});
|
||||
const res = await REQUEST(createApp(store), "GET", "/api/tasks/FN-100/stranded-refinement");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.promptExists).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -991,6 +991,35 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/tasks/stranded-refinements", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rawFreshnessMinutes = req.query.freshnessMinutes;
|
||||
let freshnessThresholdMs: number | undefined;
|
||||
if (rawFreshnessMinutes !== undefined) {
|
||||
const value = Array.isArray(rawFreshnessMinutes) ? rawFreshnessMinutes[0] : rawFreshnessMinutes;
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1440) {
|
||||
throw badRequest("freshnessMinutes must be a positive integer <= 1440");
|
||||
}
|
||||
freshnessThresholdMs = parsed * 60 * 1000;
|
||||
}
|
||||
|
||||
const items = await scopedStore.listStrandedRefinements({ freshnessThresholdMs });
|
||||
res.json({
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
recommendation: strandedReasonRecommendation(item.reasons),
|
||||
})),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Get single task with prompt content
|
||||
router.get("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
@@ -1128,35 +1157,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
return "safe to expedite";
|
||||
};
|
||||
|
||||
router.get("/tasks/stranded-refinements", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rawFreshnessMinutes = req.query.freshnessMinutes;
|
||||
let freshnessThresholdMs: number | undefined;
|
||||
if (rawFreshnessMinutes !== undefined) {
|
||||
const value = Array.isArray(rawFreshnessMinutes) ? rawFreshnessMinutes[0] : rawFreshnessMinutes;
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1440) {
|
||||
throw badRequest("freshnessMinutes must be a positive integer <= 1440");
|
||||
}
|
||||
freshnessThresholdMs = parsed * 60 * 1000;
|
||||
}
|
||||
|
||||
const items = await scopedStore.listStrandedRefinements({ freshnessThresholdMs });
|
||||
res.json({
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
recommendation: strandedReasonRecommendation(item.reasons),
|
||||
})),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/tasks/:id/stranded-refinement", async (req, res) => {
|
||||
try {
|
||||
const { existsSync } = await import("node:fs");
|
||||
@@ -1237,19 +1237,30 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
return res.json({ task, expedited: false, requiresOperatorAction: "retry-stuck" });
|
||||
}
|
||||
|
||||
if (!task.nextRecoveryAt) {
|
||||
const stranded = await scopedStore.listStrandedRefinements();
|
||||
const strandedEntry = stranded.find((item) => item.task.id === task.id);
|
||||
const hasExpediteLog = (task.log ?? []).some((entry) => entry.action === "Refinement expedited");
|
||||
const canExpedite = strandedEntry?.reasons.includes("untriaged-stale") || strandedEntry?.reasons.includes("recovery-backoff");
|
||||
|
||||
if (hasExpediteLog && !task.nextRecoveryAt) {
|
||||
return res.json({ task, expedited: true, alreadyExpedited: true });
|
||||
}
|
||||
|
||||
if (!canExpedite) {
|
||||
return res.json({ task, expedited: false, alreadyExpedited: false, reason: "not-stranded" });
|
||||
}
|
||||
|
||||
const updated = await scopedStore.updateTask(task.id, {
|
||||
nextRecoveryAt: undefined,
|
||||
});
|
||||
await scopedStore.logEntry(task.id, "Refinement expedited", "Cleared nextRecoveryAt");
|
||||
await scopedStore.logEntry(task.id, "Refinement expedited", strandedEntry?.reasons.includes("recovery-backoff")
|
||||
? "Cleared nextRecoveryAt"
|
||||
: "Expedite request recorded for stale refinement");
|
||||
|
||||
res.json({
|
||||
task: updated,
|
||||
expedited: true,
|
||||
alreadyExpedited: false,
|
||||
alreadyExpedited: hasExpediteLog && !task.nextRecoveryAt,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
Reference in New Issue
Block a user