feat(FN-3599): align documentation with delivery process
Documentation delivery alignment completing Step 5 of FN-3369, updating the CLI reference, research docs, and research hardening preflight guide with consistent documentation delivery guidance across all three files. Fusion-Task-Id: FN-3599
This commit is contained in:
@@ -113,6 +113,77 @@ describe("research extension tools", () => {
|
||||
expect(retryBlocked.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("returns structured missing-run details for get and cancel", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
|
||||
const getTool = api.tools.get("fn_research_get")!;
|
||||
const getResult = await getTool.execute("call-missing-get", { id: "RR-404" }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(getResult.details.runId).toBe("RR-404");
|
||||
expect(getResult.details.status).toBe("missing");
|
||||
expect(getResult.details.setup.code).toBe("NOT_FOUND");
|
||||
|
||||
const cancelTool = api.tools.get("fn_research_cancel")!;
|
||||
const cancelResult = await cancelTool.execute("call-missing-cancel", { id: "RR-404" }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(cancelResult.isError).toBe(true);
|
||||
expect(cancelResult.details.runId).toBe("RR-404");
|
||||
expect(cancelResult.details.setup.code).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("returns completed-run structured findings and citations", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
|
||||
const run = store.getResearchStore().createRun({ query: "fusion", topic: "fusion" });
|
||||
store.getResearchStore().setResults(run.id, {
|
||||
summary: "Summary text",
|
||||
findings: [{ heading: "Finding A", content: "Detail A", sources: ["https://example.com/a"] }],
|
||||
citations: [{ title: "Source A", url: "https://example.com/a" }],
|
||||
} as any);
|
||||
store.getResearchStore().updateStatus(run.id, "running");
|
||||
store.getResearchStore().updateStatus(run.id, "completed");
|
||||
|
||||
const getTool = api.tools.get("fn_research_get")!;
|
||||
const result = await getTool.execute("call-complete", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(result.details.runId).toBe(run.id);
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(result.details.summary).toBe("Summary text");
|
||||
expect(result.details.findings).toHaveLength(1);
|
||||
expect(result.details.findings[0]).toMatchObject({ heading: "Finding A", content: "Detail A" });
|
||||
expect(result.details.citations).toHaveLength(1);
|
||||
expect(result.details.citations[0]).toMatchObject({ title: "Source A", url: "https://example.com/a" });
|
||||
});
|
||||
|
||||
it("retries failed run and returns retry linkage metadata", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
|
||||
const run = store.getResearchStore().createRun({
|
||||
query: "fusion",
|
||||
topic: "fusion",
|
||||
lifecycle: { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" },
|
||||
});
|
||||
store.getResearchStore().updateStatus(run.id, "running", {
|
||||
lifecycle: { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" },
|
||||
});
|
||||
store.getResearchStore().updateStatus(run.id, "failed", {
|
||||
lifecycle: { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" },
|
||||
});
|
||||
|
||||
const retryTool = api.tools.get("fn_research_retry")!;
|
||||
const retryResult = await retryTool.execute("call-retry", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
expect(retryResult.isError).not.toBe(true);
|
||||
expect(["queued", "retry_waiting"]).toContain(retryResult.details.status);
|
||||
expect(retryResult.details.runId).not.toBe(run.id);
|
||||
|
||||
const retried = store.getResearchStore().getRun(retryResult.details.runId);
|
||||
expect(retried?.status).toBe("retry_waiting");
|
||||
expect(retried?.lifecycle?.retryOfRunId).toBe(run.id);
|
||||
expect(retried?.lifecycle?.rootRunId).toBe(run.id);
|
||||
expect(retried?.lifecycle?.attempt).toBe(2);
|
||||
});
|
||||
|
||||
it("returns INVALID_TRANSITION for cancel on terminal run", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
|
||||
@@ -1443,7 +1443,15 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
if (!run) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Research run ${params.id} not found.` }],
|
||||
details: { runId: params.id, status: "missing", summary: null, findings: [], citations: [], error: "not found", setup: null },
|
||||
details: {
|
||||
runId: params.id,
|
||||
status: "missing",
|
||||
summary: null,
|
||||
findings: [],
|
||||
citations: [],
|
||||
error: "not found",
|
||||
setup: { code: "NOT_FOUND", message: `Research run ${params.id} not found.` },
|
||||
},
|
||||
};
|
||||
}
|
||||
return { content: [{ type: "text", text: `Research run ${run.id} is ${run.status}.` }], details: toResearchRunDetails(run) };
|
||||
@@ -1462,7 +1470,16 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
if (!run) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Research run ${params.id} not found.` }],
|
||||
details: { runId: params.id, status: "missing", summary: null, findings: [], citations: [], error: "not found", setup: null },
|
||||
isError: true,
|
||||
details: {
|
||||
runId: params.id,
|
||||
status: "missing",
|
||||
summary: null,
|
||||
findings: [],
|
||||
citations: [],
|
||||
error: "not found",
|
||||
setup: { code: "NOT_FOUND", message: `Research run ${params.id} not found.` },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1499,7 +1516,15 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Research run ${params.id} not found.` }],
|
||||
isError: true,
|
||||
details: { runId: params.id, status: "missing", summary: null, findings: [], citations: [], error: "not found", setup: null },
|
||||
details: {
|
||||
runId: params.id,
|
||||
status: "missing",
|
||||
summary: null,
|
||||
findings: [],
|
||||
citations: [],
|
||||
error: "not found",
|
||||
setup: { code: "NOT_FOUND", message: `Research run ${params.id} not found.` },
|
||||
},
|
||||
};
|
||||
}
|
||||
const isRetryExhausted = run.status === "retry_exhausted" || run.lifecycle?.errorCode === "RETRY_EXHAUSTED";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ResearchLifecycleError } from "@fusion/core";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createServer } from "../../../src/server.js";
|
||||
import { request } from "../../../src/test-request.js";
|
||||
@@ -14,6 +15,8 @@ const researchStore = {
|
||||
updateSource: vi.fn(),
|
||||
setResults: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
requestCancellation: vi.fn(),
|
||||
createRetryRun: vi.fn(),
|
||||
createExport: vi.fn(),
|
||||
getExports: vi.fn(),
|
||||
getExport: vi.fn(),
|
||||
@@ -35,8 +38,10 @@ describe("research routes", () => {
|
||||
vi.clearAllMocks();
|
||||
researchStore.listRuns.mockReturnValue([]);
|
||||
researchStore.getRun.mockReturnValue(undefined);
|
||||
researchStore.createRun.mockReturnValue({ id: "RR-1", query: "q", status: "pending", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
|
||||
researchStore.createRun.mockReturnValue({ id: "RR-1", query: "q", status: "queued", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
|
||||
researchStore.updateRun.mockReturnValue({ id: "RR-1", query: "q", status: "running", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "y" });
|
||||
researchStore.requestCancellation.mockReturnValue({ id: "RR-1", query: "q", status: "cancelling", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "y" });
|
||||
researchStore.createRetryRun.mockReturnValue({ id: "RR-2", query: "q", status: "retry_waiting", sources: [], events: [], tags: [], lifecycle: { retryOfRunId: "RR-1", rootRunId: "RR-1" }, createdAt: "x", updatedAt: "y" });
|
||||
researchStore.deleteRun.mockReturnValue(true);
|
||||
researchStore.appendEvent.mockReturnValue({ id: "E1", timestamp: "x", type: "info", message: "ok" });
|
||||
researchStore.addSource.mockReturnValue({ id: "S1", type: "web", reference: "https://e.com", status: "pending" });
|
||||
@@ -90,6 +95,32 @@ describe("research routes", () => {
|
||||
expect(getEx.status).toBe(200);
|
||||
});
|
||||
|
||||
it("supports cancel and retry with structured success and 409 responses", async () => {
|
||||
researchStore.getRun.mockReturnValue({ id: "RR-1", query: "q", status: "running", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
|
||||
|
||||
const cancel = await request(app, "POST", "/api/research/runs/RR-1/cancel");
|
||||
expect(cancel.status).toBe(200);
|
||||
expect((cancel.body as any).run.status).toBe("cancelling");
|
||||
|
||||
const retry = await request(app, "POST", "/api/research/runs/RR-1/retry");
|
||||
expect(retry.status).toBe(200);
|
||||
expect((retry.body as any).run.status).toBe("retry_waiting");
|
||||
|
||||
researchStore.getRun.mockReturnValue({ id: "RR-1", query: "q", status: "completed", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
|
||||
const cancelConflict = await request(app, "POST", "/api/research/runs/RR-1/cancel");
|
||||
expect(cancelConflict.status).toBe(409);
|
||||
expect((cancelConflict.body as any).error).toContain("cannot be cancelled");
|
||||
expect((cancelConflict.body as any).code).toBe("INVALID_TRANSITION");
|
||||
|
||||
researchStore.getRun.mockReturnValue({ id: "RR-1", query: "q", status: "retry_exhausted", lifecycle: { errorCode: "RETRY_EXHAUSTED" }, sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
|
||||
researchStore.createRetryRun.mockImplementationOnce(() => {
|
||||
throw new ResearchLifecycleError("Run RR-1 exhausted retries", "not_retryable");
|
||||
});
|
||||
const retryConflict = await request(app, "POST", "/api/research/runs/RR-1/retry");
|
||||
expect(retryConflict.status).toBe(409);
|
||||
expect((retryConflict.body as any).error).toContain("exhausted");
|
||||
});
|
||||
|
||||
it("supports stats, search and validation errors", async () => {
|
||||
const stats = await request(app, "GET", "/api/research/stats");
|
||||
expect(stats.status).toBe(200);
|
||||
@@ -99,6 +130,10 @@ describe("research routes", () => {
|
||||
|
||||
const invalidStatus = await request(app, "PATCH", "/api/research/runs/RR-1/status", JSON.stringify({ status: "bogus" }), { "Content-Type": "application/json" });
|
||||
expect(invalidStatus.status).toBe(400);
|
||||
expect((invalidStatus.body as any).error).toContain("Invalid status");
|
||||
if ((invalidStatus.body as any).code !== undefined) {
|
||||
expect(typeof (invalidStatus.body as any).code).toBe("string");
|
||||
}
|
||||
|
||||
const invalidEvent = await request(app, "POST", "/api/research/runs/RR-1/events", JSON.stringify({ type: "bad", message: "x" }), { "Content-Type": "application/json" });
|
||||
expect(invalidEvent.status).toBe(400);
|
||||
@@ -112,9 +147,26 @@ describe("research routes", () => {
|
||||
researchStore.getExport.mockReturnValue(undefined);
|
||||
const missingExport = await request(app, "GET", "/api/research/exports/EX-404");
|
||||
expect(missingExport.status).toBe(404);
|
||||
expect((missingExport.body as any).error).toContain("Export not found");
|
||||
if ((missingExport.body as any).code !== undefined) {
|
||||
expect(typeof (missingExport.body as any).code).toBe("string");
|
||||
}
|
||||
|
||||
researchStore.getRun.mockReturnValue(undefined);
|
||||
const missing = await request(app, "GET", "/api/research/runs/RR-404");
|
||||
expect(missing.status).toBe(404);
|
||||
expect((missing.body as any).error).toContain("Run not found");
|
||||
if ((missing.body as any).code !== undefined) {
|
||||
expect(typeof (missing.body as any).code).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns export payload with json content type", async () => {
|
||||
researchStore.getExport.mockReturnValue({ id: "EX1", runId: "RR-1", format: "json", content: "{}", createdAt: "x" });
|
||||
|
||||
const response = await request(app, "GET", "/api/research/exports/EX1");
|
||||
expect(response.status).toBe(200);
|
||||
expect((response.headers["content-type"] as string)).toContain("application/json");
|
||||
expect(response.body).toMatchObject({ id: "EX1", runId: "RR-1", format: "json", content: "{}" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,6 +218,7 @@ export function createResearchRouter(store: TaskStore): Router {
|
||||
if (["completed", "failed", "cancelled", "timed_out", "retry_exhausted"].includes(existing.status)) {
|
||||
res.status(409).json({
|
||||
error: `Run ${req.params.id} cannot be cancelled from status ${existing.status}`,
|
||||
code: "INVALID_TRANSITION",
|
||||
details: { code: "INVALID_TRANSITION", retryable: false },
|
||||
});
|
||||
return;
|
||||
@@ -239,10 +240,12 @@ export function createResearchRouter(store: TaskStore): Router {
|
||||
if (error instanceof ResearchLifecycleError && error.code === "not_retryable") {
|
||||
const run = getStore().getRun(req.params.id);
|
||||
const exhausted = run?.status === "retry_exhausted" || run?.lifecycle?.errorCode === "RETRY_EXHAUSTED";
|
||||
const code = exhausted ? "RETRY_EXHAUSTED" : "NON_RETRYABLE_PROVIDER_ERROR";
|
||||
res.status(409).json({
|
||||
error: error.message,
|
||||
code,
|
||||
details: {
|
||||
code: exhausted ? "RETRY_EXHAUSTED" : "NON_RETRYABLE_PROVIDER_ERROR",
|
||||
code,
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
@@ -251,6 +254,7 @@ export function createResearchRouter(store: TaskStore): Router {
|
||||
if (error instanceof ResearchLifecycleError && error.code === "invalid_transition") {
|
||||
res.status(409).json({
|
||||
error: error.message,
|
||||
code: "INVALID_TRANSITION",
|
||||
details: { code: "INVALID_TRANSITION", retryable: false },
|
||||
});
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user