feat(FN-4829): merge fusion/fn-4829
This commit is contained in:
@@ -291,13 +291,47 @@ export interface ReviseTaskReviewResponse {
|
||||
reviewState: NonNullable<TaskDetail["reviewState"]>;
|
||||
}
|
||||
|
||||
export interface DuplicateMatch {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
column: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export class DuplicateCandidatesError extends Error {
|
||||
readonly matches: DuplicateMatch[];
|
||||
|
||||
constructor(matches: DuplicateMatch[]) {
|
||||
super("duplicate_candidates");
|
||||
this.name = "DuplicateCandidatesError";
|
||||
this.matches = matches;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateTaskRequestOptions {
|
||||
transportNodeId?: string;
|
||||
localNodeId?: string;
|
||||
}
|
||||
|
||||
export function createTask(
|
||||
input: TaskCreateInput,
|
||||
export type CreateTaskInput = TaskCreateInput & {
|
||||
acknowledgedDuplicates?: string[];
|
||||
bypassDuplicateCheck?: boolean;
|
||||
};
|
||||
|
||||
export async function checkDuplicateTasks(
|
||||
input: { title?: string; description: string },
|
||||
projectId?: string,
|
||||
): Promise<DuplicateMatch[]> {
|
||||
const response = await api<{ matches?: DuplicateMatch[] }>(withProjectId("/tasks/duplicate-check", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return response.matches ?? [];
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
input: CreateTaskInput,
|
||||
projectId?: string,
|
||||
options?: CreateTaskRequestOptions,
|
||||
): Promise<Task> {
|
||||
@@ -326,9 +360,12 @@ export function createTask(
|
||||
branch,
|
||||
baseBranch,
|
||||
githubTracking,
|
||||
acknowledgedDuplicates,
|
||||
bypassDuplicateCheck,
|
||||
} = input;
|
||||
|
||||
return proxyApi<Task>(withProjectId("/tasks", projectId), {
|
||||
try {
|
||||
return await proxyApi<Task>(withProjectId("/tasks", projectId), {
|
||||
method: "POST",
|
||||
nodeId: options?.transportNodeId,
|
||||
localNodeId: options?.localNodeId,
|
||||
@@ -357,8 +394,19 @@ export function createTask(
|
||||
branch,
|
||||
baseBranch,
|
||||
githubTracking,
|
||||
acknowledgedDuplicates,
|
||||
bypassDuplicateCheck,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiRequestError && error.status === 409 && error.message === "duplicate_candidates") {
|
||||
const matches = Array.isArray(error.details?.matches)
|
||||
? (error.details?.matches as DuplicateMatch[])
|
||||
: [];
|
||||
throw new DuplicateCandidatesError(matches);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTask(
|
||||
@@ -5665,6 +5713,7 @@ export interface ActivityFeedEntry {
|
||||
| "task:deleted"
|
||||
| "task:merged"
|
||||
| "task:failed"
|
||||
| "task:duplicate-warning-overridden"
|
||||
| "settings:updated"
|
||||
| "project:isolation-transition";
|
||||
projectId: string;
|
||||
|
||||
@@ -31,6 +31,7 @@ const TYPE_CONFIG: Record<ActivityFeedEntry["type"], {
|
||||
"task:deleted": { label: "Deleted", icon: XCircle, color: "var(--color-error)" },
|
||||
"task:merged": { label: "Merged", icon: GitMerge, color: "var(--color-success)" },
|
||||
"task:failed": { label: "Failed", icon: AlertTriangle, color: "var(--color-error)" },
|
||||
"task:duplicate-warning-overridden": { label: "Duplicate Override", icon: AlertTriangle, color: "var(--color-warning)" },
|
||||
"settings:updated": { label: "Settings", icon: Settings, color: "var(--text-muted)" },
|
||||
"project:isolation-transition": { label: "Isolation", icon: Folder, color: "var(--color-info)" },
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ const EVENT_TYPE_LABELS: Record<ActivityEventType, string> = {
|
||||
"task:deleted": "Task Deleted",
|
||||
"task:merged": "Task Merged",
|
||||
"task:failed": "Task Failed",
|
||||
"task:duplicate-warning-overridden": "Duplicate Warning Overridden",
|
||||
"settings:updated": "Settings Updated",
|
||||
"project:isolation-transition": "Project Isolation Transition",
|
||||
};
|
||||
@@ -41,6 +42,7 @@ const EVENT_TYPE_ICONS: Record<ActivityEventType, React.ReactNode> = {
|
||||
"task:deleted": <X size={14} className="activity-icon deleted" />,
|
||||
"task:merged": <CheckCircle size={14} className="activity-icon merged" />,
|
||||
"task:failed": <XCircle size={14} className="activity-icon failed" />,
|
||||
"task:duplicate-warning-overridden": <AlertCircle size={14} className="activity-icon updated" />,
|
||||
"settings:updated": <Settings size={14} className="activity-icon settings" />,
|
||||
"project:isolation-transition": <Folder size={14} className="activity-icon settings" />,
|
||||
};
|
||||
|
||||
@@ -468,6 +468,16 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
onOpenTask={(taskId) => {
|
||||
const matchingTask = (allTasks ?? []).find((candidate) => candidate.id === taskId);
|
||||
if (matchingTask) {
|
||||
onOpenDetail(matchingTask);
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.hash = `#/tasks/${taskId}`;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{column === "in-progress" ? (
|
||||
|
||||
62
packages/dashboard/app/components/DuplicateWarningModal.css
Normal file
62
packages/dashboard/app/components/DuplicateWarningModal.css
Normal file
@@ -0,0 +1,62 @@
|
||||
.duplicate-warning-modal {
|
||||
max-width: min(100%, calc(var(--space-2xl) * 18));
|
||||
}
|
||||
|
||||
.duplicate-warning-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: 0 var(--space-lg) var(--space-md);
|
||||
}
|
||||
|
||||
.duplicate-warning-modal-copy {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.duplicate-warning-modal-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
max-height: calc(var(--space-2xl) * 8);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.duplicate-warning-modal-item {
|
||||
cursor: default;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.duplicate-warning-modal-item-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.duplicate-warning-modal-score {
|
||||
margin-left: auto;
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.duplicate-warning-modal-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.duplicate-warning-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.duplicate-warning-modal {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
69
packages/dashboard/app/components/DuplicateWarningModal.tsx
Normal file
69
packages/dashboard/app/components/DuplicateWarningModal.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import "./DuplicateWarningModal.css";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { DuplicateMatch } from "../api";
|
||||
|
||||
interface DuplicateWarningModalProps {
|
||||
matches: DuplicateMatch[];
|
||||
onOpen: (id: string) => void;
|
||||
onProceed: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function toStatusClass(column: string): string {
|
||||
return `card-status-badge--${column}`;
|
||||
}
|
||||
|
||||
export function DuplicateWarningModal({ matches, onOpen, onProceed, onCancel }: DuplicateWarningModalProps) {
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cancelButtonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" role="presentation">
|
||||
<div className="modal duplicate-warning-modal" role="dialog" aria-modal="true" aria-labelledby="duplicate-warning-modal-title">
|
||||
<div className="modal-header">
|
||||
<h3 id="duplicate-warning-modal-title">Possible duplicates</h3>
|
||||
</div>
|
||||
<div className="duplicate-warning-modal-body">
|
||||
<p className="duplicate-warning-modal-copy">We found similar active tasks. Open an existing task or create this one anyway.</p>
|
||||
<div className="duplicate-warning-modal-list">
|
||||
{matches.map((match) => (
|
||||
<article className="card duplicate-warning-modal-item" key={match.id}>
|
||||
<div className="duplicate-warning-modal-item-header">
|
||||
<span className="card-id">{match.id}</span>
|
||||
<span className={`card-status-badge ${toStatusClass(match.column)}`}>{match.column}</span>
|
||||
<span className="duplicate-warning-modal-score">{Math.round(match.score * 100)}%</span>
|
||||
</div>
|
||||
<div className="card-title duplicate-warning-modal-title">{match.title || "Untitled task"}</div>
|
||||
<div className="duplicate-warning-modal-actions">
|
||||
<button className="btn btn-sm" type="button" onClick={() => onOpen(match.id)}>Open</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<div className="modal-actions-left">
|
||||
<button className="btn" type="button" ref={cancelButtonRef} onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
<div className="modal-actions-right">
|
||||
<button className="btn btn-primary" type="button" onClick={onProceed}>Create anyway</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1514,6 +1514,16 @@ export function ListView({
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
onOpenTask={(taskId) => {
|
||||
const matchingTask = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (matchingTask) {
|
||||
onOpenDetail(matchingTask);
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.hash = `#/tasks/${taskId}`;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{filteredCount === 0 ? (
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core";
|
||||
import type { Task, TaskCreateInput, Settings, TaskPriority } from "@fusion/core";
|
||||
import type { ModelInfo, RefinementType, Agent } from "../api";
|
||||
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment } from "../api";
|
||||
import type { Task, Settings, TaskPriority } from "@fusion/core";
|
||||
import type { ModelInfo, RefinementType, Agent, CreateTaskInput, DuplicateMatch } from "../api";
|
||||
import { checkDuplicateTasks, fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment } from "../api";
|
||||
import { DuplicateWarningModal } from "./DuplicateWarningModal";
|
||||
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Flag } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
@@ -23,7 +24,7 @@ interface PendingImage {
|
||||
}
|
||||
|
||||
interface QuickEntryBoxProps {
|
||||
onCreate?: (input: TaskCreateInput) => Promise<Task | void>;
|
||||
onCreate?: (input: CreateTaskInput) => Promise<Task | void>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
tasks?: Task[];
|
||||
availableModels?: ModelInfo[];
|
||||
@@ -63,6 +64,7 @@ interface QuickEntryBoxProps {
|
||||
* Toggle favorite model callback from shared app-level state.
|
||||
*/
|
||||
onToggleModelFavorite?: (modelId: string) => void;
|
||||
onOpenTask?: (id: string) => void;
|
||||
}
|
||||
|
||||
function getNodeStatusLabel(status: NodeInfo["status"]): string {
|
||||
@@ -92,7 +94,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
||||
};
|
||||
}
|
||||
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, projectId, autoExpand = true, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite }: QuickEntryBoxProps) {
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, projectId, autoExpand = true, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
|
||||
const [description, setDescription] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return getScopedItem(STORAGE_KEY, projectId) || "";
|
||||
@@ -161,8 +163,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const [githubTrackingOverride, setGithubTrackingOverride] = useState<boolean | null>(null);
|
||||
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
|
||||
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
|
||||
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
|
||||
const { nodes } = useNodes();
|
||||
|
||||
// AI Refinement state
|
||||
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
@@ -489,13 +491,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed || isSubmitting || !onCreate) return;
|
||||
const submitCreateTask = useCallback(async (trimmed: string, overrides?: { acknowledgedDuplicates?: string[] }) => {
|
||||
if (!onCreate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalDescription = description;
|
||||
setIsSubmitting(true);
|
||||
// Optimistically clear text for rapid entry; restore on failure.
|
||||
setDescription("");
|
||||
try {
|
||||
const createdTask = await onCreate({
|
||||
@@ -516,6 +518,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
: undefined,
|
||||
priority,
|
||||
nodeId,
|
||||
acknowledgedDuplicates: overrides?.acknowledgedDuplicates,
|
||||
});
|
||||
if (createdTask && pendingImages.length > 0) {
|
||||
const failures: string[] = [];
|
||||
@@ -531,22 +534,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
addToast(`Failed to upload: ${failures.join(", ")}`, "error");
|
||||
}
|
||||
}
|
||||
// Clear input for rapid entry
|
||||
resetForm();
|
||||
// Note: Focus restoration is handled by useEffect when isSubmitting becomes false
|
||||
} catch (err) {
|
||||
setDescription(originalDescription);
|
||||
addToast(getErrorMessage(err) || "Failed to create task", "error");
|
||||
// Keep input content on failure so user can retry
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
description,
|
||||
isSubmitting,
|
||||
onCreate,
|
||||
description,
|
||||
dependencies,
|
||||
selectedAgentId,
|
||||
selectedPresetId,
|
||||
hasExecutorOverride,
|
||||
executorProvider,
|
||||
executorModelId,
|
||||
@@ -556,17 +556,58 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
hasPlanningOverride,
|
||||
planningProvider,
|
||||
planningModelId,
|
||||
isFastMode,
|
||||
settings,
|
||||
githubTrackingOverride,
|
||||
priority,
|
||||
nodeId,
|
||||
pendingImages,
|
||||
projectId,
|
||||
addToast,
|
||||
resetForm,
|
||||
isFastMode,
|
||||
githubTrackingOverride,
|
||||
settings,
|
||||
priority,
|
||||
nodeId,
|
||||
]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed || isSubmitting || !onCreate) return;
|
||||
|
||||
try {
|
||||
const matches = await checkDuplicateTasks({ description: trimmed }, projectId);
|
||||
if (matches.length > 0) {
|
||||
setDuplicateMatches(matches);
|
||||
return;
|
||||
}
|
||||
} catch (_error) {
|
||||
addToast("Duplicate check failed; creating task anyway.", "error");
|
||||
}
|
||||
|
||||
await submitCreateTask(trimmed);
|
||||
}, [description, isSubmitting, onCreate, projectId, submitCreateTask, addToast]);
|
||||
|
||||
const handleDuplicateOpen = useCallback((taskId: string) => {
|
||||
if (onOpenTask) {
|
||||
onOpenTask(taskId);
|
||||
} else if (typeof window !== "undefined") {
|
||||
window.location.hash = `#/tasks/${taskId}`;
|
||||
}
|
||||
setDuplicateMatches(null);
|
||||
}, [onOpenTask]);
|
||||
|
||||
const handleDuplicateProceed = useCallback(async () => {
|
||||
const trimmed = description.trim();
|
||||
const matches = duplicateMatches;
|
||||
if (!trimmed || !matches || matches.length === 0) {
|
||||
setDuplicateMatches(null);
|
||||
return;
|
||||
}
|
||||
setDuplicateMatches(null);
|
||||
await submitCreateTask(trimmed, { acknowledgedDuplicates: matches.map((match) => match.id) });
|
||||
}, [description, duplicateMatches, submitCreateTask]);
|
||||
|
||||
const handleDuplicateCancel = useCallback(() => {
|
||||
setDuplicateMatches(null);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -1357,7 +1398,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`quick-entry-box ${isDisclosureExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}`} data-testid="quick-entry-box">
|
||||
<>
|
||||
<div className={`quick-entry-box ${isDisclosureExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}`} data-testid="quick-entry-box">
|
||||
<div className="description-with-refine">
|
||||
<div className="quick-entry-main-row">
|
||||
<div className="quick-entry-textarea-wrap">
|
||||
@@ -2045,5 +2087,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
Enter to create · Esc to cancel
|
||||
</div>
|
||||
</div>
|
||||
{duplicateMatches && (
|
||||
<DuplicateWarningModal
|
||||
matches={duplicateMatches}
|
||||
onOpen={handleDuplicateOpen}
|
||||
onProceed={handleDuplicateProceed}
|
||||
onCancel={handleDuplicateCancel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { DuplicateWarningModal } from "../DuplicateWarningModal";
|
||||
import type { DuplicateMatch } from "../../api";
|
||||
|
||||
const matches: DuplicateMatch[] = [
|
||||
{ id: "FN-101", title: "Fix duplicate task flow", description: "...", column: "todo", score: 0.81 },
|
||||
{ id: "FN-102", title: "Another duplicate", description: "...", column: "in-progress", score: 0.67 },
|
||||
];
|
||||
|
||||
describe("DuplicateWarningModal", () => {
|
||||
it("renders one row per match with id and title", () => {
|
||||
render(<DuplicateWarningModal matches={matches} onOpen={vi.fn()} onProceed={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("FN-101")).toBeInTheDocument();
|
||||
expect(screen.getByText("FN-102")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fix duplicate task flow")).toBeInTheDocument();
|
||||
expect(screen.getByText("Another duplicate")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onOpen with the selected id", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<DuplicateWarningModal matches={matches} onOpen={onOpen} onProceed={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Open" })[1]);
|
||||
expect(onOpen).toHaveBeenCalledWith("FN-102");
|
||||
});
|
||||
|
||||
it("calls onProceed", () => {
|
||||
const onProceed = vi.fn();
|
||||
render(<DuplicateWarningModal matches={matches} onOpen={vi.fn()} onProceed={onProceed} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create anyway" }));
|
||||
expect(onProceed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onCancel for cancel click and Escape", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<DuplicateWarningModal matches={matches} onOpen={vi.fn()} onProceed={vi.fn()} onCancel={onCancel} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { QuickEntryBox } from "../QuickEntryBox";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { fetchSettings, fetchAgents, uploadAttachment } from "../../api";
|
||||
import { checkDuplicateTasks, fetchSettings, fetchAgents, uploadAttachment } from "../../api";
|
||||
import { useNodes } from "../../hooks/useNodes";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
|
||||
@@ -98,6 +98,7 @@ vi.mock("../../api", () => ({
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
checkDuplicateTasks: vi.fn().mockResolvedValue([]),
|
||||
uploadAttachment: vi.fn().mockResolvedValue({}),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
@@ -256,6 +257,7 @@ describe("QuickEntryBox", () => {
|
||||
healthCheck: vi.fn(),
|
||||
});
|
||||
vi.mocked(uploadAttachment).mockResolvedValue({} as any);
|
||||
vi.mocked(checkDuplicateTasks).mockResolvedValue([]);
|
||||
|
||||
Object.defineProperty(URL, "createObjectURL", {
|
||||
configurable: true,
|
||||
@@ -3334,4 +3336,66 @@ describe("QuickEntryBox", () => {
|
||||
|
||||
expect(screen.getByTestId("quick-entry-node-button")).toHaveTextContent("Node Two");
|
||||
});
|
||||
|
||||
describe("FN-4829 duplicate detection", () => {
|
||||
it("opens duplicate warning modal and does not create immediately", async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
|
||||
{ id: "FN-123", title: "Duplicate", description: "desc", column: "todo", score: 0.9 },
|
||||
]);
|
||||
renderQuickEntryBox({ onCreate });
|
||||
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(input, { target: { value: "duplicate candidate" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(await screen.findByText("Possible duplicates")).toBeInTheDocument();
|
||||
expect(onCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates immediately when duplicate check has no matches", async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([]);
|
||||
renderQuickEntryBox({ onCreate });
|
||||
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(input, { target: { value: "fresh task" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
|
||||
expect(onCreate.mock.calls[0]?.[0]).toHaveProperty("acknowledgedDuplicates", undefined);
|
||||
});
|
||||
|
||||
it("sends acknowledgedDuplicates when creating anyway", async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
|
||||
{ id: "FN-456", title: "Duplicate", description: "desc", column: "todo", score: 0.7 },
|
||||
]);
|
||||
renderQuickEntryBox({ onCreate });
|
||||
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(input, { target: { value: "maybe duplicate" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Create anyway" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ acknowledgedDuplicates: ["FN-456"] }));
|
||||
});
|
||||
});
|
||||
|
||||
it("continues creation when duplicate check fails and shows toast", async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
const addToast = vi.fn();
|
||||
vi.mocked(checkDuplicateTasks).mockRejectedValueOnce(new Error("boom"));
|
||||
renderQuickEntryBox({ onCreate, addToast });
|
||||
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(input, { target: { value: "task despite failure" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
|
||||
expect(addToast).toHaveBeenCalledWith("Duplicate check failed; creating task anyway.", "error");
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { Column, Task, TaskStore } from "@fusion/core";
|
||||
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
import { registerTaskWorkflowRoutes } from "../routes/register-task-workflow-routes.js";
|
||||
import { ApiError, sendErrorResponse } from "../api-error.js";
|
||||
|
||||
function createTaskFixture(overrides: Partial<Task> & { id: string; description: string; column: Column }): Task {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: overrides.id,
|
||||
description: overrides.description,
|
||||
column: overrides.column,
|
||||
dependencies: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
size: "M",
|
||||
subtasks: [],
|
||||
log: [],
|
||||
tags: [],
|
||||
blockedBy: [],
|
||||
source: { sourceType: "api" },
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function buildApp(seed: Task[] = []) {
|
||||
const tasks = [...seed];
|
||||
const recordActivity = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const store: Partial<TaskStore> = {
|
||||
searchTasks: vi.fn().mockImplementation(async () => tasks),
|
||||
getSettingsFast: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
|
||||
createTask: vi.fn().mockImplementation(async (input: { title?: string; description: string; source?: Record<string, unknown> }) => {
|
||||
const task = createTaskFixture({
|
||||
id: `FN-${tasks.length + 100}`,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
column: "todo",
|
||||
source: (input.source as Task["source"]) ?? { sourceType: "api" },
|
||||
});
|
||||
tasks.push(task);
|
||||
return task;
|
||||
}),
|
||||
recordActivity,
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
registerTaskWorkflowRoutes(
|
||||
{
|
||||
router,
|
||||
store: store as TaskStore,
|
||||
options: {},
|
||||
runtimeLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never,
|
||||
planningLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never,
|
||||
chatLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never,
|
||||
getProjectIdFromRequest: () => undefined,
|
||||
getScopedStore: async () => store as TaskStore,
|
||||
getProjectContext: async () => ({ store: store as TaskStore, engine: undefined, projectId: undefined }),
|
||||
prioritizeProjectsForCurrentDirectory: (projects) => projects,
|
||||
emitRemoteRouteDiagnostic: () => {},
|
||||
emitAuthSyncAuditLog: () => {},
|
||||
parseScopeParam: () => undefined,
|
||||
resolveAutomationStore: () => ({}) as never,
|
||||
resolveRoutineStore: () => ({}) as never,
|
||||
resolveRoutineRunner: () => ({}) as never,
|
||||
registerDispose: () => {},
|
||||
dispose: () => {},
|
||||
rethrowAsApiError: (error: unknown): never => {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ApiError(500, error instanceof Error ? error.message : "Internal server error");
|
||||
},
|
||||
},
|
||||
{
|
||||
runtimeLogger: { error: vi.fn(), warn: vi.fn() },
|
||||
upload: { single: () => (_req: unknown, _res: unknown, next: () => void) => next() },
|
||||
taskDetailActivityLogLimit: 100,
|
||||
validateOptionalModelField: (value) => (typeof value === "string" ? value : undefined),
|
||||
normalizeModelSelectionPair: (provider, modelId) => ({ provider: provider ?? null, modelId: modelId ?? null }),
|
||||
runGitCommand: async () => "",
|
||||
trimTaskDetailActivityLog: (task) => task,
|
||||
triggerCommentWakeForAssignedAgent: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", router);
|
||||
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
if (error instanceof ApiError) {
|
||||
sendErrorResponse(res, error.statusCode, error.message, { details: error.details });
|
||||
return;
|
||||
}
|
||||
sendErrorResponse(res, 500, error instanceof Error ? error.message : "Internal server error");
|
||||
});
|
||||
|
||||
return { app, store, recordActivity };
|
||||
}
|
||||
|
||||
describe("task duplicate detection routes", () => {
|
||||
it("POST /tasks/duplicate-check returns matches for high similarity", async () => {
|
||||
const { app } = buildApp([
|
||||
createTaskFixture({
|
||||
id: "FN-10",
|
||||
title: "Add duplicate task warning",
|
||||
description: "Warn before creating duplicate tasks from quick entry",
|
||||
column: "todo",
|
||||
}),
|
||||
]);
|
||||
|
||||
const res = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks/duplicate-check",
|
||||
JSON.stringify({ description: "Warn before creating duplicate tasks from quick entry" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { matches: Array<{ id: string }> };
|
||||
expect(body.matches.map((match) => match.id)).toContain("FN-10");
|
||||
});
|
||||
|
||||
it("returns empty matches for unrelated descriptions", async () => {
|
||||
const { app } = buildApp([
|
||||
createTaskFixture({ id: "FN-11", title: "Retry scheduler", description: "Adjust retry windows", column: "todo" }),
|
||||
]);
|
||||
|
||||
const res = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks/duplicate-check",
|
||||
JSON.stringify({ description: "Completely unrelated modal styling issue" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { matches: unknown[] }).matches).toEqual([]);
|
||||
});
|
||||
|
||||
it("POST /tasks returns 409 when duplicate exists without acknowledgement", async () => {
|
||||
const { app } = buildApp([
|
||||
createTaskFixture({ id: "FN-12", title: "Duplicate warning", description: "Warn before task creation", column: "todo" }),
|
||||
]);
|
||||
|
||||
const res = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Warn before task creation" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const body = res.body as { error: string; details: { matches: Array<{ id: string }> } };
|
||||
expect(body.error).toBe("duplicate_candidates");
|
||||
expect(body.details.matches.map((match) => match.id)).toContain("FN-12");
|
||||
});
|
||||
|
||||
it("creates task with override metadata and records activity", async () => {
|
||||
const { app, store, recordActivity } = buildApp([
|
||||
createTaskFixture({ id: "FN-13", title: "Duplicate warning", description: "Warn before task creation", column: "todo" }),
|
||||
]);
|
||||
|
||||
const res = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Warn before task creation", acknowledgedDuplicates: ["FN-13"] }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const created = res.body as Task;
|
||||
expect(created.source?.sourceMetadata?.duplicateWarningOverridden).toBe(true);
|
||||
expect(created.source?.sourceMetadata?.acknowledgedDuplicateIds).toEqual(["FN-13"]);
|
||||
expect(recordActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "task:duplicate-warning-overridden",
|
||||
metadata: expect.objectContaining({ acknowledgedDuplicateIds: ["FN-13"] }),
|
||||
}),
|
||||
);
|
||||
expect((store.createTask as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("bypassDuplicateCheck creates task without override metadata", async () => {
|
||||
const { app } = buildApp([
|
||||
createTaskFixture({ id: "FN-14", title: "Duplicate warning", description: "Warn before task creation", column: "todo" }),
|
||||
]);
|
||||
|
||||
const res = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Warn before task creation", bypassDuplicateCheck: true }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const created = res.body as Task;
|
||||
expect(created.source?.sourceMetadata?.duplicateWarningOverridden).toBeUndefined();
|
||||
});
|
||||
|
||||
it("done tasks do not trigger conflict", async () => {
|
||||
const { app } = buildApp([
|
||||
createTaskFixture({ id: "FN-15", title: "Duplicate warning", description: "Warn before task creation", column: "done" }),
|
||||
]);
|
||||
|
||||
const res = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Warn before task creation" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
@@ -89,8 +89,8 @@ export function notFound(message: string): ApiError {
|
||||
return new ApiError(404, message);
|
||||
}
|
||||
|
||||
export function conflict(message: string): ApiError {
|
||||
return new ApiError(409, message);
|
||||
export function conflict(message: string, details?: Record<string, unknown>): ApiError {
|
||||
return new ApiError(409, message, details);
|
||||
}
|
||||
|
||||
export function rateLimited(message: string, retryAfter?: number): ApiError {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import type { TaskStore, Task, TaskDetail, Column, TaskReviewData, TaskReviewItem, TaskReviewSummary, GithubIssueAction } from "@fusion/core";
|
||||
import type {
|
||||
TaskStore,
|
||||
Task,
|
||||
TaskDetail,
|
||||
Column,
|
||||
TaskReviewData,
|
||||
TaskReviewItem,
|
||||
TaskReviewSummary,
|
||||
GithubIssueAction,
|
||||
DuplicateCandidate,
|
||||
DuplicateMatch,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
COLUMNS,
|
||||
TASK_PRIORITIES,
|
||||
@@ -11,6 +22,7 @@ import {
|
||||
canAgentTakeImplementationTaskForExplicitRouting,
|
||||
formatRoleMismatchReason,
|
||||
getCurrentRepo,
|
||||
findDuplicateMatches,
|
||||
} from "@fusion/core";
|
||||
import { GitHubClient } from "../github.js";
|
||||
import { createTrackingIssueForTask } from "../github-tracking-hook.js";
|
||||
@@ -23,6 +35,53 @@ import { resolveBranchSelection } from "./branch-selection.js";
|
||||
const REVIEW_BLOCK_RE = /##\s+(Code|Plan)\s+Review:[\s\S]*?(?=\n##\s+(?:Code|Plan)\s+Review:|$)/gi;
|
||||
const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||
const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||
const DUPLICATE_STOPWORDS = new Set(["a", "an", "the", "and", "or", "of", "to", "for", "in", "is", "on", "with", "fn"]);
|
||||
|
||||
function buildDuplicateQuery(title: string | undefined, description: string): string {
|
||||
const tokens = `${title ?? ""} ${description}`
|
||||
.toLowerCase()
|
||||
.split(/\W+/)
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0 && !DUPLICATE_STOPWORDS.has(token));
|
||||
|
||||
const deduped = [...new Set(tokens)];
|
||||
const selected = deduped.sort((left, right) => right.length - left.length).slice(0, 5);
|
||||
return selected.join(" ");
|
||||
}
|
||||
|
||||
async function computeDuplicateMatches(
|
||||
scopedStore: TaskStore,
|
||||
input: { title?: string; description: string; limit?: number; threshold?: number },
|
||||
): Promise<DuplicateMatch[]> {
|
||||
const query = buildDuplicateQuery(input.title, input.description);
|
||||
if (query.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await scopedStore.searchTasks(query, {
|
||||
slim: true,
|
||||
includeArchived: false,
|
||||
limit: 20,
|
||||
});
|
||||
const candidates: DuplicateCandidate[] = results.map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title ?? "",
|
||||
description: task.description ?? "",
|
||||
column: task.column,
|
||||
}));
|
||||
|
||||
return findDuplicateMatches(
|
||||
{
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
},
|
||||
candidates,
|
||||
{
|
||||
threshold: input.threshold,
|
||||
limit: input.limit ?? 5,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function buildReviewerAgentItemId(input: { index: number; reviewType: "plan" | "code"; step?: number; verdict?: string; createdAt?: string }): string {
|
||||
const stepPart = input.step ? `step-${input.step}` : "step-na";
|
||||
@@ -155,6 +214,38 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/tasks/duplicate-check", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { title, description, limit, threshold } = req.body ?? {};
|
||||
|
||||
if (typeof description !== "string" || description.trim().length === 0) {
|
||||
throw badRequest("description is required");
|
||||
}
|
||||
if (limit !== undefined && (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1)) {
|
||||
throw badRequest("limit must be a positive integer");
|
||||
}
|
||||
if (threshold !== undefined && (typeof threshold !== "number" || Number.isNaN(threshold))) {
|
||||
throw badRequest("threshold must be a number");
|
||||
}
|
||||
|
||||
const matches = await computeDuplicateMatches(scopedStore, {
|
||||
title: typeof title === "string" ? title : undefined,
|
||||
description: description.trim(),
|
||||
limit,
|
||||
threshold,
|
||||
});
|
||||
|
||||
res.json({ matches });
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Create task
|
||||
router.post("/tasks", async (req, res) => {
|
||||
try {
|
||||
@@ -183,10 +274,22 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
branchSelection,
|
||||
nodeId,
|
||||
githubTracking,
|
||||
acknowledgedDuplicates,
|
||||
bypassDuplicateCheck,
|
||||
} = req.body;
|
||||
if (!description || typeof description !== "string") {
|
||||
throw badRequest("description is required");
|
||||
}
|
||||
if (
|
||||
acknowledgedDuplicates !== undefined
|
||||
&& (!Array.isArray(acknowledgedDuplicates)
|
||||
|| !acknowledgedDuplicates.every((taskId: unknown) => typeof taskId === "string" && /^[A-Z]+-\d+$/.test(taskId)))
|
||||
) {
|
||||
throw badRequest("acknowledgedDuplicates must be an array of task IDs");
|
||||
}
|
||||
if (bypassDuplicateCheck !== undefined && typeof bypassDuplicateCheck !== "boolean") {
|
||||
throw badRequest("bypassDuplicateCheck must be a boolean");
|
||||
}
|
||||
if (breakIntoSubtasks !== undefined && typeof breakIntoSubtasks !== "boolean") {
|
||||
throw badRequest("breakIntoSubtasks must be a boolean");
|
||||
}
|
||||
@@ -301,9 +404,23 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedDescription = description.trim();
|
||||
const normalizedTitle = typeof title === "string" ? title : undefined;
|
||||
const acknowledgedDuplicateIds = acknowledgedDuplicates ?? [];
|
||||
const duplicateMatches = bypassDuplicateCheck === true
|
||||
? []
|
||||
: await computeDuplicateMatches(scopedStore, {
|
||||
title: normalizedTitle,
|
||||
description: normalizedDescription,
|
||||
});
|
||||
const matchesAfterAckFilter = duplicateMatches.filter((match) => !acknowledgedDuplicateIds.includes(match.id));
|
||||
if (matchesAfterAckFilter.length > 0) {
|
||||
throw conflict("duplicate_candidates", { matches: matchesAfterAckFilter });
|
||||
}
|
||||
|
||||
const createInput = {
|
||||
title,
|
||||
description,
|
||||
title: normalizedTitle,
|
||||
description: normalizedDescription,
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
@@ -320,7 +437,17 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
reviewLevel: reviewLevel ?? undefined,
|
||||
executionMode: executionMode || undefined,
|
||||
priority: priority ?? undefined,
|
||||
source: normalizedSource,
|
||||
source:
|
||||
acknowledgedDuplicateIds.length > 0
|
||||
? {
|
||||
...(normalizedSource as Record<string, unknown>),
|
||||
sourceMetadata: {
|
||||
...((normalizedSource as { sourceMetadata?: Record<string, unknown> }).sourceMetadata ?? {}),
|
||||
duplicateWarningOverridden: true,
|
||||
acknowledgedDuplicateIds,
|
||||
},
|
||||
}
|
||||
: normalizedSource,
|
||||
branch: normalizedBranch,
|
||||
baseBranch: normalizedBaseBranch,
|
||||
...(typeof nodeId === "string" && nodeId.trim().length > 0 ? { nodeId: nodeId.trim() } : {}),
|
||||
@@ -332,6 +459,27 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } },
|
||||
);
|
||||
await createTrackingIssueForTask(scopedStore, task, { githubToken: options?.githubToken });
|
||||
|
||||
if (acknowledgedDuplicateIds.length > 0) {
|
||||
try {
|
||||
await scopedStore.recordActivity({
|
||||
type: "task:duplicate-warning-overridden",
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
details: `Created despite ${acknowledgedDuplicateIds.length} possible duplicate(s): ${acknowledgedDuplicateIds.join(", ")}`,
|
||||
metadata: {
|
||||
acknowledgedDuplicateIds,
|
||||
matches: matchesAfterAckFilter.map((match) => ({ id: match.id, score: match.score })),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
runtimeLogger.warn("Failed to record duplicate warning override activity", {
|
||||
taskId: task.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(task);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -11,7 +11,7 @@ const qualityAppTests = [
|
||||
"app/api/**/*.test.ts",
|
||||
// Representative workflow/component coverage. Exhaustive modal/view suites
|
||||
// stay available in the full `dashboard-app` project.
|
||||
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ReliabilityView,ResearchView,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,ThemeSelectorSwatchContract,TrackingRepoSelect,WorktrunkInstallApprovalDetails,WorkflowResultsTab}.test.tsx",
|
||||
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ReliabilityView,ResearchView,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,ThemeSelectorSwatchContract,TrackingRepoSelect,WorktrunkInstallApprovalDetails,WorkflowResultsTab}.test.tsx",
|
||||
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
||||
"app/context/**/*.test.tsx",
|
||||
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
|
||||
@@ -21,7 +21,7 @@ const qualityAppTests = [
|
||||
const qualityApiTests = [
|
||||
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
||||
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
|
||||
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-settings,routes-task-commit-associations,routes-tasks,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
|
||||
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-duplicate-check,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
|
||||
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,stash-recovery-routes}.test.ts",
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user