feat(FN-4918): merge fusion/fn-4918

This commit is contained in:
gsxdsm
2026-05-18 16:23:06 -07:00
parent 0bf89e7813
commit 0046fc92e2
11 changed files with 564 additions and 10 deletions

View File

@@ -1,10 +1,82 @@
import { describe, expect, it } from "vitest";
import {
computeContentFingerprint,
findDuplicateMatches,
type DuplicateCandidate,
} from "../duplicate-detection.js";
describe("computeContentFingerprint", () => {
it("returns identical fingerprint for identical title and description", () => {
const first = computeContentFingerprint({
title: "Duplicate warning route",
description: "Warn before creating duplicate tasks",
});
const second = computeContentFingerprint({
title: "Duplicate warning route",
description: "Warn before creating duplicate tasks",
});
expect(first).toBe(second);
});
it("normalizes whitespace, casing, and trailing punctuation", () => {
const first = computeContentFingerprint({
title: "Duplicate Warning Route",
description: "Warn before creating duplicate tasks!!!",
});
const second = computeContentFingerprint({
title: " duplicate warning route ",
description: "warn before creating duplicate tasks",
});
expect(first).toBe(second);
});
it("returns different fingerprints for different titles", () => {
const first = computeContentFingerprint({
title: "Duplicate warning route",
description: "Warn before creating duplicate tasks",
});
const second = computeContentFingerprint({
title: "Retry counter badge placement",
description: "Warn before creating duplicate tasks",
});
expect(first).not.toBe(second);
});
it("returns null for empty or whitespace-only descriptions", () => {
expect(
computeContentFingerprint({
title: "Duplicate warning route",
description: " ",
}),
).toBeNull();
expect(
computeContentFingerprint({
title: "Duplicate warning route",
description: "...",
}),
).toBeNull();
});
it("matches the FN-4909/FN-4910 reproduction pair", () => {
const first = computeContentFingerprint({
title: "Move retry counter badge next to GitHub tracking badge",
description:
"Move the retry counter badge to the left of the GitHub tracking badge",
});
const second = computeContentFingerprint({
title: "Move retry counter badge next to GitHub tracking badge",
description:
"Move the retry counter badge to the left of the GitHub tracking badge.",
});
expect(first).toBe(second);
});
});
describe("findDuplicateMatches", () => {
it("returns high-similarity title+description matches", () => {
const candidates: DuplicateCandidate[] = [

View File

@@ -1,3 +1,5 @@
import { createHash } from "node:crypto";
import type { Column } from "./types.js";
export interface DuplicateMatch {
@@ -20,6 +22,11 @@ export interface DuplicateCandidate {
column: Column;
}
export interface ContentFingerprintInput {
title?: string | null;
description: string;
}
const DEFAULT_THRESHOLD = 0.45;
const DEFAULT_LIMIT = 5;
const DEFAULT_EXCLUDE_COLUMNS: Column[] = ["done", "archived"];
@@ -39,6 +46,34 @@ const STOPWORDS = new Set([
"fn",
]);
function normalizeFingerprintPart(value: string): string {
return value
.toLowerCase()
.replace(/[,.;:!?"'`(){}]+/g, "")
.replaceAll("[", "")
.replaceAll("]", "")
.replace(/$/u, "")
.replace(/\s+/g, " ")
.trim();
}
/**
* Deterministic dedup fingerprint for task content.
*/
export function computeContentFingerprint(
input: ContentFingerprintInput,
): string | null {
const normalizedDescription = normalizeFingerprintPart(input.description);
if (normalizedDescription.length === 0) {
return null;
}
const normalizedTitle = normalizeFingerprintPart(input.title ?? "");
return createHash("sha256")
.update(`${normalizedTitle}\n${normalizedDescription}`)
.digest("hex");
}
function tokenize(value: string): string[] {
return value
.toLowerCase()

View File

@@ -131,7 +131,9 @@ export {
SelfDefeatingDependencyError,
} from "./store.js";
export {
computeContentFingerprint,
findDuplicateMatches,
type ContentFingerprintInput,
type DuplicateCandidate,
type DuplicateMatch,
type DuplicateMatchInput,

View File

@@ -4020,6 +4020,33 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return limit >= 0 ? matches.slice(0, limit) : matches;
}
async findRecentTasksByContentFingerprint(
fingerprint: string,
options?: { windowMs?: number; includeArchived?: boolean },
): Promise<Task[]> {
const trimmedFingerprint = fingerprint.trim();
if (trimmedFingerprint.length === 0) {
return [];
}
const requestedWindowMs = options?.windowMs ?? 60_000;
const windowMs = Math.max(1, Math.min(300_000, Math.trunc(requestedWindowMs)));
const cutoffIso = new Date(Date.now() - windowMs).toISOString();
const includeArchived = options?.includeArchived ?? false;
const selectClause = this.getTaskSelectClause(false, "t");
const rows = this.db.prepare(`
SELECT ${selectClause}
FROM tasks t
WHERE json_extract(t.sourceMetadata, '$.contentFingerprint') = ?
AND t.createdAt >= ?
${includeArchived ? "" : "AND t.\"column\" != 'archived'"}
ORDER BY t.createdAt ASC
`).all(trimmedFingerprint, cutoffIso) as TaskRow[];
return rows.map((row) => this.rowToTask(row));
}
async getTasksByAssignedAgent(
agentId: string,
options?: { pausedOnly?: boolean; excludeArchived?: boolean },

View File

@@ -875,6 +875,7 @@ export type ActivityEventType =
| "task:merged"
| "task:failed"
| "task:duplicate-warning-overridden"
| "task:auto-archived-deterministic-duplicate"
| "task:auto-archived-ghost-bug"
| "task:auto-archived-duplicate"
| "settings:updated"