Stop silent near-duplicate auto-archive by routing duplicates through explicit user confirmation in API, engine, and dashboard flows. - add new near-duplicate confirmation metadata/types and preserve duplicate task rows until confirmation - update workflow routes and triage logic to return confirmation-required outcomes instead of immediate archive - add TaskCard and TaskDetailModal UI/actions plus styling and hook updates for confirming or rejecting duplicate handling - expand dashboard and engine tests and task-management docs to cover the new confirmation path Files changed: docs/task-management.md | 14 ++++- packages/core/src/types.ts | 4 ++ packages/dashboard/app/api/legacy.ts | 1 + packages/dashboard/app/components/TaskCard.css | 59 ++++++++++++++++++ packages/dashboard/app/components/TaskCard.tsx | 46 +++++++++++++- packages/dashboard/app/components/TaskDetailModal.css | 34 +++++++++++ packages/dashboard/app/components/TaskDetailModal.tsx | 71 +++++++++++++++++++++- packages/dashboard/app/components/__tests__/TaskCard.test.tsx | 71 ++++++++++++++++++++++ packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx | 68 +++++++++++++++++++++ packages/dashboard/app/hooks/useTasks.ts | 2 +- packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts | 45 ++++++++++++++ packages/dashboard/src/routes/register-task-workflow-routes.ts | 9 ++- packages/engine/src/__tests__/reliability-interactions/near-duplicate-intake.test.ts | 16 +++-- packages/engine/src/triage.ts | 19 +++--- 14 files changed, 433 insertions(+), 26 deletions(-) Fusion-Task-Id: FN-5731 Fusion-Task-Lineage: fa004129-7bce-4457-b8c3-ceb9fb953319
184 lines
7.7 KiB
TypeScript
184 lines
7.7 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { execSync } from "node:child_process";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { DEFAULT_SETTINGS, TaskStore } from "@fusion/core";
|
|
import * as core from "@fusion/core";
|
|
import { TriageProcessor } from "../../triage.js";
|
|
|
|
function git(cwd: string, command: string): string {
|
|
return execSync(command, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
}
|
|
|
|
async function createFixture() {
|
|
const rootDir = await mkdtemp(join(tmpdir(), "fusion-near-dup-"));
|
|
git(rootDir, "git init -b main");
|
|
git(rootDir, 'git config user.email "test@example.com"');
|
|
git(rootDir, 'git config user.name "Test User"');
|
|
git(rootDir, "git commit --allow-empty -m init");
|
|
|
|
const store = new TaskStore(rootDir, undefined, { inMemoryDb: true });
|
|
await store.init();
|
|
await store.updateSettings({ ...DEFAULT_SETTINGS, requirePlanApproval: false });
|
|
const triage = new TriageProcessor(store, rootDir);
|
|
|
|
return {
|
|
rootDir,
|
|
store,
|
|
triage,
|
|
cleanup: async () => {
|
|
store.close();
|
|
await rm(rootDir, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
const basePrompt = `# Task: FN-1 - test\n\n**Size:** S\n\n## Review Level: 1\n\n## File Scope\n- packages/dashboard/src/routes/register-git-github.ts\n`;
|
|
|
|
describe("reliability interactions: near-duplicate intake", () => {
|
|
const fixtures: Array<Awaited<ReturnType<typeof createFixture>>> = [];
|
|
afterEach(async () => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
while (fixtures.length) await fixtures.pop()!.cleanup();
|
|
});
|
|
|
|
it("flags newer task as near-duplicate and records activity", async () => {
|
|
const fx = await createFixture();
|
|
fixtures.push(fx);
|
|
|
|
await fx.store.createTask({
|
|
title: "Create PR routes missing handlers",
|
|
description: "Missing /api/tasks/:id/pr/options and /api/tasks/:id/pr/preflight and /api/tasks/:id/pr/generate-metadata",
|
|
column: "todo",
|
|
});
|
|
const incoming = await fx.store.createTask({
|
|
title: "Missing handlers for create PR routes",
|
|
description: "GET /api/tasks/:id/pr/options and GET /api/tasks/:id/pr/preflight and POST /api/tasks/:id/pr/generate-metadata all fail",
|
|
});
|
|
|
|
await (fx.triage as any).finalizeApprovedTask(incoming, basePrompt, await fx.store.getSettings(), {});
|
|
|
|
const updated = await fx.store.getTask(incoming.id);
|
|
expect(updated.column).toBe("todo");
|
|
expect(updated.sourceMetadata?.nearDuplicateOf).toBeTruthy();
|
|
expect(typeof updated.sourceMetadata?.nearDuplicateScore).toBe("number");
|
|
expect(Array.isArray(updated.sourceMetadata?.nearDuplicateSharedTokens)).toBe(true);
|
|
const flaggedActivity = await fx.store.getActivityLog({ type: "task:near-duplicate-flagged", limit: 20 });
|
|
expect(flaggedActivity.some((entry) => entry.taskId === incoming.id)).toBe(true);
|
|
const archivedActivity = await fx.store.getActivityLog({ type: "task:auto-archived-near-duplicate", limit: 20 });
|
|
expect(archivedActivity.some((entry) => entry.taskId === incoming.id)).toBe(false);
|
|
});
|
|
|
|
it("does not archive generic file overlap only", async () => {
|
|
const fx = await createFixture();
|
|
fixtures.push(fx);
|
|
|
|
await fx.store.createTask({
|
|
title: "Fix PR comments pagination",
|
|
description: "Touch register-git-github.ts pagination only",
|
|
column: "todo",
|
|
});
|
|
const incoming = await fx.store.createTask({
|
|
title: "Add PR merge auto-rebase option",
|
|
description: "Touch register-git-github.ts merge behavior only",
|
|
});
|
|
|
|
await (fx.triage as any).finalizeApprovedTask(incoming, basePrompt, await fx.store.getSettings(), {});
|
|
const updated = await fx.store.getTask(incoming.id);
|
|
expect(updated.column).toBe("todo");
|
|
});
|
|
|
|
it("does not archive when candidate is older than window", async () => {
|
|
vi.useFakeTimers();
|
|
const fx = await createFixture();
|
|
fixtures.push(fx);
|
|
|
|
vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z"));
|
|
await fx.store.createTask({
|
|
title: "Create PR routes missing handlers",
|
|
description: "Missing /api/tasks/:id/pr/options and /api/tasks/:id/pr/preflight and /api/tasks/:id/pr/generate-metadata",
|
|
column: "todo",
|
|
});
|
|
|
|
vi.setSystemTime(new Date("2026-05-10T00:00:00.000Z"));
|
|
const incoming = await fx.store.createTask({
|
|
title: "Missing handlers for create PR routes",
|
|
description: "GET /api/tasks/:id/pr/options and GET /api/tasks/:id/pr/preflight and POST /api/tasks/:id/pr/generate-metadata all fail",
|
|
});
|
|
await (fx.triage as any).finalizeApprovedTask(incoming, basePrompt, await fx.store.getSettings(), {});
|
|
const updated = await fx.store.getTask(incoming.id);
|
|
expect(updated.column).toBe("todo");
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("fails open when extractor throws", async () => {
|
|
const fx = await createFixture();
|
|
fixtures.push(fx);
|
|
|
|
const incoming = await fx.store.createTask({
|
|
title: "Missing handlers for create PR routes",
|
|
description: "GET /api/tasks/:id/pr/options and GET /api/tasks/:id/pr/preflight and POST /api/tasks/:id/pr/generate-metadata all fail",
|
|
});
|
|
vi.spyOn(core, "extractIntentSignature").mockImplementation(() => {
|
|
throw new Error("boom");
|
|
});
|
|
|
|
await (fx.triage as any).finalizeApprovedTask(incoming, basePrompt, await fx.store.getSettings(), {});
|
|
const updated = await fx.store.getTask(incoming.id);
|
|
expect(updated.column).toBe("todo");
|
|
});
|
|
|
|
it("does not archive older task when newer candidate exists", async () => {
|
|
const fx = await createFixture();
|
|
fixtures.push(fx);
|
|
|
|
const older = await fx.store.createTask({
|
|
title: "Create PR routes missing handlers",
|
|
description: "Missing /api/tasks/:id/pr/options and /api/tasks/:id/pr/preflight and /api/tasks/:id/pr/generate-metadata",
|
|
});
|
|
const newer = await fx.store.createTask({
|
|
title: "Missing handlers for create PR routes",
|
|
description: "GET /api/tasks/:id/pr/options and GET /api/tasks/:id/pr/preflight and POST /api/tasks/:id/pr/generate-metadata all fail",
|
|
column: "todo",
|
|
});
|
|
|
|
await (fx.triage as any).finalizeApprovedTask(older, basePrompt, await fx.store.getSettings(), {});
|
|
|
|
const updatedOlder = await fx.store.getTask(older.id);
|
|
expect(updatedOlder.column).not.toBe("archived");
|
|
expect(updatedOlder.column).toBe("todo");
|
|
const updatedNewer = await fx.store.getTask(newer.id);
|
|
expect(updatedNewer.column).toBe("todo");
|
|
});
|
|
|
|
it("does not auto-archive siblings when both near-duplicates finalize in the same millisecond", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-05-19T12:00:00.000Z"));
|
|
|
|
const fx = await createFixture();
|
|
fixtures.push(fx);
|
|
|
|
const first = await fx.store.createTask({
|
|
title: "Create PR routes missing handlers",
|
|
description: "Missing /api/tasks/:id/pr/options and /api/tasks/:id/pr/preflight and /api/tasks/:id/pr/generate-metadata",
|
|
});
|
|
const second = await fx.store.createTask({
|
|
title: "Missing handlers for create PR routes",
|
|
description: "GET /api/tasks/:id/pr/options and GET /api/tasks/:id/pr/preflight and POST /api/tasks/:id/pr/generate-metadata all fail",
|
|
});
|
|
|
|
const settings = await fx.store.getSettings();
|
|
await Promise.all([
|
|
(fx.triage as any).finalizeApprovedTask(first, basePrompt, settings, {}),
|
|
(fx.triage as any).finalizeApprovedTask(second, basePrompt, settings, {}),
|
|
]);
|
|
|
|
const refreshed = await fx.store.listTasks({ includeArchived: true });
|
|
const archived = refreshed.filter((task) => task.id === first.id || task.id === second.id).filter((task) => task.column === "archived");
|
|
expect(archived.length).toBe(0);
|
|
vi.useRealTimers();
|
|
});
|
|
});
|