Enable Quick Add and task editing to select merger models and thinking levels. - Persist merger model and thinking overrides through task APIs, storage, and PostgreSQL migrations. - Add merger-lane selection controls to Quick Add and model settings interfaces. - Apply task merger settings to merger and PR fallback sessions, with regression coverage. - Document the merger lane and include a release changeset. Files changed: .changeset/fn-8126-quick-add-merger-lane.md | 7 ++ docs/dashboard-guide.md | 2 + docs/settings-reference.md | 7 +- .../core/src/__tests__/model-resolution.test.ts | 9 ++ packages/core/src/index.gate.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/model-resolution.ts | 20 ++++ .../core/src/postgres/migrations/0000_initial.sql | 3 + .../migrations/0017_task_merger_model_lane.sql | 4 + packages/core/src/postgres/schema-applier.ts | 14 ++- packages/core/src/postgres/schema/project.ts | 3 + packages/core/src/store.ts | 2 +- .../core/src/task-store/archive-lifecycle-2.ts | 6 ++ packages/core/src/task-store/persistence.ts | 6 ++ packages/core/src/task-store/remaining-ops-2.ts | 4 +- packages/core/src/task-store/remaining-ops-6.ts | 2 +- packages/core/src/task-store/serialization.ts | 6 ++ packages/core/src/task-store/task-creation.ts | 6 ++ packages/core/src/task-store/task-row-mappers.ts | 4 +- packages/core/src/task-store/task-update.ts | 6 ++ packages/core/src/types.ts | 18 ++++ packages/dashboard/app/api/tasks.ts | 13 +++ .../dashboard/app/components/InlineCreateCard.tsx | 33 ++++++- .../app/components/ModelSelectionModal.tsx | 29 ++++++ .../dashboard/app/components/ModelSelectorTab.tsx | 101 +++++++++++++++++++-- .../dashboard/app/components/QuickEntryBox.tsx | 44 +++++++-- .../__tests__/ModelSelectionModal.test.tsx | 20 ++++ .../components/__tests__/ModelSelectorTab.test.tsx | 37 +++++++- .../src/routes/register-task-workflow-routes.ts | 27 +++++- .../src/__tests__/agent-session-helpers.test.ts | 8 ++ packages/engine/src/agent-session-helpers.ts | 16 +++- packages/engine/src/merger-ai.ts | 14 +-- packages/engine/src/merger.ts | 35 ++++--- packages/engine/src/pr-response-run-ops.ts | 7 +- 34 files changed, 451 insertions(+), 64 deletions(-) Fusion-Task-Id: FN-8126 Fusion-Task-Lineage: 3fc81801-6d77-4e11-9cf0-3af37313930e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
420 lines
12 KiB
TypeScript
420 lines
12 KiB
TypeScript
/**
|
|
* FNXC:CodeOrganization 2026-07-16-01:00:
|
|
* Task CRUD client API (list/detail/create/update/move) peeled from legacy.ts.
|
|
*/
|
|
import type {
|
|
Task,
|
|
TaskDetail,
|
|
TaskCreateInput,
|
|
ColumnId,
|
|
TaskPriority,
|
|
TaskSourceIssue,
|
|
TaskGitLabTracking,
|
|
TaskGitLabTrackedItem,
|
|
GithubIssueAction,
|
|
} from "@fusion/core";
|
|
import { withTokenHeader } from "../auth";
|
|
import { api, ApiRequestError, buildApiUrl, proxyApi } from "./client.js";
|
|
import { withProjectId } from "./health.js";
|
|
|
|
/** Options that shape the soft-delete request payload/query, not hard-delete behavior. */
|
|
export interface DeleteTaskOptions {
|
|
removeDependencyReferences?: boolean;
|
|
removeLineageReferences?: boolean;
|
|
githubIssueAction?: GithubIssueAction;
|
|
allowResurrection?: boolean;
|
|
}
|
|
|
|
export interface ArchiveTaskOptions {
|
|
removeLineageReferences?: boolean;
|
|
}
|
|
|
|
export function fetchTasks(
|
|
limit?: number,
|
|
offset?: number,
|
|
projectId?: string,
|
|
q?: string,
|
|
includeArchived?: boolean,
|
|
): Promise<Task[]> {
|
|
const search = new URLSearchParams();
|
|
if (limit !== undefined) search.set("limit", String(limit));
|
|
if (offset !== undefined) search.set("offset", String(offset));
|
|
if (projectId) search.set("projectId", projectId);
|
|
if (q) search.set("q", q);
|
|
if (includeArchived) search.set("includeArchived", "1");
|
|
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
|
return api<Task[]>(`/tasks${suffix}`);
|
|
}
|
|
|
|
/**
|
|
* FNXC:ArchivePagination 2026-07-08-00:00:
|
|
* Dedicated paged read for the Archived board column (FN-7659). Returns
|
|
* one bounded page (default 100) ordered `archivedAt DESC` plus `total`/
|
|
* `hasMore` so the caller can drive a "Show more" affordance without ever
|
|
* fetching the whole archive in one request.
|
|
*/
|
|
export function fetchArchivedTasks(
|
|
projectId?: string,
|
|
limit?: number,
|
|
offset?: number,
|
|
): Promise<{ tasks: Task[]; total: number; hasMore: boolean }> {
|
|
const search = new URLSearchParams();
|
|
if (limit !== undefined) search.set("limit", String(limit));
|
|
if (offset !== undefined) search.set("offset", String(offset));
|
|
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
|
return api<{ tasks: Task[]; total: number; hasMore: boolean }>(withProjectId(`/tasks/archived${suffix}`, projectId));
|
|
}
|
|
|
|
export async function fetchTaskDetail(id: string, projectId?: string): Promise<TaskDetail> {
|
|
const maxAttempts = 2; // 1 initial + 1 retry
|
|
const url = buildApiUrl(withProjectId(`/tasks/${id}`, projectId));
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
const res = await fetch(url, {
|
|
headers: withTokenHeader({ "Content-Type": "application/json" }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) return data as TaskDetail;
|
|
if (attempt === maxAttempts) {
|
|
throw new Error((data as { error?: string }).error || "Request failed");
|
|
}
|
|
}
|
|
// unreachable
|
|
throw new Error("Request failed");
|
|
}
|
|
|
|
export interface TaskRuntimeFallbackResponse {
|
|
taskId: string;
|
|
hasEvent: boolean;
|
|
wasConfigured: boolean | null;
|
|
runtimeHint: string | null;
|
|
reason: string | null;
|
|
eventId: string | null;
|
|
timestamp: string | null;
|
|
showFallbackBadge: boolean;
|
|
}
|
|
|
|
/**
|
|
* Fetch the most recent session:runtime-resolved audit event for a task,
|
|
* normalized for the runtime-fallback badge/toast affordance. Used by
|
|
* useRuntimeFallbackStatus.
|
|
*/
|
|
export async function fetchTaskRuntimeFallback(
|
|
taskId: string,
|
|
projectId?: string,
|
|
): Promise<TaskRuntimeFallbackResponse> {
|
|
return api<TaskRuntimeFallbackResponse>(withProjectId(`/tasks/${taskId}/runtime-fallback`, projectId));
|
|
}
|
|
|
|
export interface UpdateTaskReviewRequest {
|
|
reviewState: TaskDetail["reviewState"] | null;
|
|
}
|
|
|
|
export interface TaskReviewResponse {
|
|
reviewState: NonNullable<TaskDetail["reviewState"]>;
|
|
automationStatus: string | null;
|
|
emptyMessage?: string | null;
|
|
prInfo?: TaskDetail["prInfo"];
|
|
}
|
|
|
|
export interface RefreshTaskReviewResponse {
|
|
reviewState: NonNullable<TaskDetail["reviewState"]>;
|
|
automationStatus: string | null;
|
|
prInfo?: TaskDetail["prInfo"];
|
|
}
|
|
|
|
export interface SelectedReviewItem {
|
|
id: string;
|
|
source: "pr-review" | "reviewer-agent";
|
|
threadId?: string;
|
|
filePath?: string;
|
|
lineNumber?: number;
|
|
author?: string;
|
|
summary: string;
|
|
body: string;
|
|
url?: string;
|
|
}
|
|
|
|
export interface ReviseTaskReviewResponse {
|
|
task: Task;
|
|
reviewState: NonNullable<TaskDetail["reviewState"]>;
|
|
}
|
|
|
|
export interface AddressPrFeedbackResponse {
|
|
task: Task;
|
|
}
|
|
|
|
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 type BranchSelectionInput = {
|
|
mode: "project-default" | "auto-new" | "existing" | "custom-new";
|
|
branchName?: string;
|
|
baseBranch?: string;
|
|
};
|
|
|
|
export type CreateTaskInput = TaskCreateInput & {
|
|
branchSelection?: BranchSelectionInput;
|
|
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> {
|
|
const {
|
|
title,
|
|
description,
|
|
column,
|
|
dependencies,
|
|
breakIntoSubtasks,
|
|
enabledWorkflowSteps,
|
|
workflowId,
|
|
assignedAgentId,
|
|
modelPresetId,
|
|
modelProvider,
|
|
modelId,
|
|
validatorModelProvider,
|
|
validatorModelId,
|
|
planningModelProvider,
|
|
planningModelId,
|
|
mergerModelProvider,
|
|
mergerModelId,
|
|
thinkingLevel,
|
|
validatorThinkingLevel,
|
|
planningThinkingLevel,
|
|
mergerThinkingLevel,
|
|
plannerOversightLevel,
|
|
summarize,
|
|
reviewLevel,
|
|
executionMode,
|
|
autoMerge,
|
|
priority,
|
|
source,
|
|
nodeId,
|
|
branch,
|
|
baseBranch,
|
|
branchSelection,
|
|
githubTracking,
|
|
sessionAdvisorEnabled,
|
|
acknowledgedDuplicates,
|
|
bypassDuplicateCheck,
|
|
} = input;
|
|
|
|
try {
|
|
return await proxyApi<Task>(withProjectId("/tasks", projectId), {
|
|
method: "POST",
|
|
nodeId: options?.transportNodeId,
|
|
localNodeId: options?.localNodeId,
|
|
body: JSON.stringify({
|
|
title,
|
|
description,
|
|
column,
|
|
dependencies,
|
|
breakIntoSubtasks,
|
|
enabledWorkflowSteps,
|
|
workflowId,
|
|
assignedAgentId,
|
|
modelPresetId,
|
|
modelProvider,
|
|
modelId,
|
|
validatorModelProvider,
|
|
validatorModelId,
|
|
planningModelProvider,
|
|
planningModelId,
|
|
mergerModelProvider,
|
|
mergerModelId,
|
|
thinkingLevel,
|
|
validatorThinkingLevel,
|
|
planningThinkingLevel,
|
|
mergerThinkingLevel,
|
|
plannerOversightLevel,
|
|
summarize,
|
|
reviewLevel,
|
|
executionMode,
|
|
autoMerge,
|
|
priority,
|
|
source,
|
|
nodeId,
|
|
branch,
|
|
baseBranch,
|
|
branchSelection,
|
|
githubTracking,
|
|
sessionAdvisorEnabled,
|
|
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 interface RepairOverlapBlockerResult {
|
|
taskId: string;
|
|
dryRun: boolean;
|
|
repaired: boolean;
|
|
statusCleared: boolean;
|
|
previousOverlapBlockedBy?: string;
|
|
currentOverlapBlockedBy?: string;
|
|
reason: string;
|
|
message: string;
|
|
task?: Task;
|
|
}
|
|
|
|
export function repairOverlapBlocker(
|
|
id: string,
|
|
options: { dryRun?: boolean; reason?: string } = {},
|
|
projectId?: string,
|
|
): Promise<RepairOverlapBlockerResult> {
|
|
return api<RepairOverlapBlockerResult>(withProjectId(`/tasks/${id}/repair-overlap-blocker`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(options),
|
|
});
|
|
}
|
|
|
|
export function updateTask(
|
|
id: string,
|
|
updates: {
|
|
title?: string;
|
|
description?: string;
|
|
prompt?: string;
|
|
dependencies?: string[];
|
|
enabledWorkflowSteps?: string[];
|
|
overlapBlockedBy?: string | null;
|
|
status?: null;
|
|
modelProvider?: string | null;
|
|
modelId?: string | null;
|
|
validatorModelProvider?: string | null;
|
|
validatorModelId?: string | null;
|
|
planningModelProvider?: string | null;
|
|
planningModelId?: string | null;
|
|
mergerModelProvider?: string | null;
|
|
mergerModelId?: string | null;
|
|
thinkingLevel?: string | null;
|
|
validatorThinkingLevel?: string | null;
|
|
planningThinkingLevel?: string | null;
|
|
mergerThinkingLevel?: string | null;
|
|
plannerOversightLevel?: "off" | "observe" | "steer" | "autonomous" | null;
|
|
/** FNXC:PlannerOversight 2026-07-14-18:11: boolean override or null to inherit project default. */
|
|
sessionAdvisorEnabled?: boolean | null;
|
|
reviewLevel?: number | null;
|
|
executionMode?: "standard" | "fast" | null;
|
|
noCommitsExpected?: boolean;
|
|
autoMerge?: boolean | null;
|
|
priority?: TaskPriority | null;
|
|
sourceIssue?: TaskSourceIssue | null;
|
|
nodeId?: string | null;
|
|
branch?: string | null;
|
|
baseBranch?: string | null;
|
|
githubTracking?: {
|
|
enabled?: boolean;
|
|
repoOverride?: string | null;
|
|
issue?: null;
|
|
} | null;
|
|
gitlabTracking?: (Omit<TaskGitLabTracking, "item"> & { item?: TaskGitLabTrackedItem | null }) | null;
|
|
dismissNearDuplicate?: boolean;
|
|
},
|
|
projectId?: string,
|
|
): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify(updates),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Batch update AI model configuration for multiple tasks.
|
|
* @param taskIds - Array of task IDs to update
|
|
* @param modelProvider - Executor model provider (optional, null to clear)
|
|
* @param modelId - Executor model ID (optional, null to clear)
|
|
* @param validatorModelProvider - Validator model provider (optional, null to clear)
|
|
* @param validatorModelId - Validator model ID (optional, null to clear)
|
|
* @param thinkingLevel - Executor thinking level (optional, null to clear)
|
|
* @returns Promise with updated tasks and count
|
|
*/
|
|
export function batchUpdateTaskModels(
|
|
taskIds: string[],
|
|
modelProvider?: string | null,
|
|
modelId?: string | null,
|
|
validatorModelProvider?: string | null,
|
|
validatorModelId?: string | null,
|
|
planningModelProvider?: string | null,
|
|
planningModelId?: string | null,
|
|
nodeId?: string | null,
|
|
thinkingLevel?: string | null,
|
|
projectId?: string,
|
|
): Promise<{ updated: Task[]; count: number }> {
|
|
return api<{ updated: Task[]; count: number }>(withProjectId("/tasks/batch-update-models", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
taskIds,
|
|
modelProvider,
|
|
modelId,
|
|
validatorModelProvider,
|
|
validatorModelId,
|
|
planningModelProvider,
|
|
planningModelId,
|
|
nodeId,
|
|
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function moveTask(
|
|
id: string,
|
|
column: ColumnId,
|
|
projectId?: string,
|
|
optionsOrPosition?: { preserveProgress?: boolean } | number,
|
|
): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/move`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
column,
|
|
...(
|
|
typeof optionsOrPosition === "object" && optionsOrPosition?.preserveProgress
|
|
? { preserveProgress: true }
|
|
: {}
|
|
),
|
|
}),
|
|
});
|
|
}
|