From 5ee049b834a3d64f7cdd0c4c63935e2351a71a9a Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 9 May 2026 22:06:46 -0700 Subject: [PATCH] feat(FN-3214): add branch selection contract to planning routes and subtask Adds branch selection and assignment to the planning workflow: defines a branch selection contract, wires it through subtask creation in SubtaskBreakdownModal, and applies the contract in planning route handlers, with corresponding test coverage. A minor typing fix in the bundled plugin views loader Fusion-Task-Id: FN-3214 --- packages/dashboard/app/api/legacy.ts | 35 ++++- .../app/components/SubtaskBreakdownModal.tsx | 50 ++++++- .../app/plugins/registerBundledPluginViews.ts | 4 +- .../src/__tests__/branch-selection.test.ts | 36 +++++ .../src/__tests__/routes-planning.test.ts | 120 +++++++++++++++++ .../src/__tests__/routes-tasks.test.ts | 71 ++++++++++ .../dashboard/src/routes/branch-selection.ts | 127 ++++++++++++++++++ .../register-planning-subtask-routes.ts | 43 +++++- .../routes/register-task-workflow-routes.ts | 15 +-- 9 files changed, 480 insertions(+), 21 deletions(-) create mode 100644 packages/dashboard/src/__tests__/branch-selection.test.ts create mode 100644 packages/dashboard/src/routes/branch-selection.ts diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index dcce615a5..a784657fc 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -4618,12 +4618,26 @@ export function createTasksFromBreakdown( subtasks: SubtaskItem[], parentTaskId?: string, projectId?: string, + options?: { + branch?: string; + baseBranch?: string; + branchSelection?: { + mode: "project-default" | "auto-new" | "existing" | "custom-new"; + branchName?: string; + baseBranch?: string; + }; + branchAssignment?: { mode: "shared" | "per-task-derived" }; + }, ): Promise<{ tasks: Task[]; parentTaskClosed?: boolean }> { return api<{ tasks: Task[]; parentTaskClosed?: boolean }>(withProjectId("/subtasks/create-tasks", projectId), { method: "POST", body: JSON.stringify({ sessionId, parentTaskId, + ...(options?.branch !== undefined ? { branch: options.branch } : {}), + ...(options?.baseBranch !== undefined ? { baseBranch: options.baseBranch } : {}), + ...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}), + ...(options?.branchAssignment ? { branchAssignment: options.branchAssignment } : {}), subtasks: subtasks.map((subtask) => ({ tempId: subtask.id, title: subtask.title, @@ -6917,11 +6931,28 @@ export function cancelMissionInterview(sessionId: string, projectId?: string, ta export function createMissionFromInterview( sessionId: string, summary?: MissionPlanSummary, - projectId?: string + projectId?: string, + options?: { + branch?: string; + baseBranch?: string; + branchSelection?: { + mode: "project-default" | "auto-new" | "existing" | "custom-new"; + branchName?: string; + baseBranch?: string; + }; + branchAssignment?: { mode: "shared" | "per-task-derived" }; + }, ): Promise { return api(withProjectId("/missions/interview/create-mission", projectId), { method: "POST", - body: JSON.stringify({ sessionId, summary }), + body: JSON.stringify({ + sessionId, + summary, + ...(options?.branch !== undefined ? { branch: options.branch } : {}), + ...(options?.baseBranch !== undefined ? { baseBranch: options.baseBranch } : {}), + ...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}), + ...(options?.branchAssignment ? { branchAssignment: options.branchAssignment } : {}), + }), }); } diff --git a/packages/dashboard/app/components/SubtaskBreakdownModal.tsx b/packages/dashboard/app/components/SubtaskBreakdownModal.tsx index 2d8b8124c..07d2566b5 100644 --- a/packages/dashboard/app/components/SubtaskBreakdownModal.tsx +++ b/packages/dashboard/app/components/SubtaskBreakdownModal.tsx @@ -98,6 +98,10 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT const [localDescription, setLocalDescription] = useState(initialDescription); const [error, setError] = useState(null); const [dirty, setDirty] = useState(false); + const [branchMode, setBranchMode] = useState<"project-default" | "auto-new" | "existing" | "custom-new">("project-default"); + const [branchName, setBranchName] = useState(""); + const [baseBranch, setBaseBranch] = useState(""); + const [branchAssignmentMode, setBranchAssignmentMode] = useState<"shared" | "per-task-derived">("shared"); // Drag-and-drop state const [draggingId, setDraggingId] = useState(null); @@ -130,8 +134,9 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT const isInvalid = useMemo(() => { if (subtasks.length === 0) return true; if (subtasks.some((subtask) => !subtask.title.trim())) return true; + if ((branchMode === "existing" || branchMode === "custom-new") && !branchName.trim()) return true; return hasDependencyCycle(subtasks); - }, [subtasks]); + }, [branchMode, branchName, subtasks]); const showSendToBackgroundButton = view.type === "generating" || view.type === "editing" || view.type === "error"; const activeLockInfo = sessionId ? activeTabMap.get(sessionId) : null; @@ -156,6 +161,10 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT setIsRetrying(false); setError(null); setDirty(false); + setBranchMode("project-default"); + setBranchName(""); + setBaseBranch(""); + setBranchAssignmentMode("shared"); autoStartedRef.current = false; }, [localDescription, projectId]); @@ -500,7 +509,14 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT setError(null); setView({ type: "creating", sessionId }); try { - const result = await createTasksFromBreakdown(sessionId, subtasks, parentTaskId, projectId); + const result = await createTasksFromBreakdown(sessionId, subtasks, parentTaskId, projectId, { + branchSelection: { + mode: branchMode, + ...(branchMode === "existing" || branchMode === "custom-new" ? { branchName } : {}), + ...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}), + }, + branchAssignment: { mode: branchAssignmentMode }, + }); onTasksCreated(result.tasks); resetState(); onClose(); @@ -508,7 +524,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT setError(getErrorMessage(err) || "Failed to create tasks"); setView({ type: "editing", sessionId }); } - }, [isInvalid, onClose, onTasksCreated, parentTaskId, projectId, resetState, sessionId, subtasks]); + }, [baseBranch, branchAssignmentMode, branchMode, branchName, isInvalid, onClose, onTasksCreated, parentTaskId, projectId, resetState, sessionId, subtasks]); const handleRetry = useCallback(async () => { if (view.type !== "error") { @@ -692,6 +708,34 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
+
+
+ + +
+ {(branchMode === "existing" || branchMode === "custom-new") && ( +
+ + setBranchName(event.target.value)} disabled={view.type === "creating"} /> +
+ )} +
+ + setBaseBranch(event.target.value)} disabled={view.type === "creating"} placeholder="main" /> +
+
+ + +
+
{subtasks.map((subtask, index) => { const isDragging = draggingId === subtask.id; const isDragOver = dragOverId === subtask.id; diff --git a/packages/dashboard/app/plugins/registerBundledPluginViews.ts b/packages/dashboard/app/plugins/registerBundledPluginViews.ts index 8e5246341..111e01f1d 100644 --- a/packages/dashboard/app/plugins/registerBundledPluginViews.ts +++ b/packages/dashboard/app/plugins/registerBundledPluginViews.ts @@ -16,7 +16,7 @@ function createMissingPluginView(moduleId: string, exportName: string): PluginVi async function loadDependencyGraphView(): Promise<{ default: PluginViewComponent }> { const moduleId = "@fusion-plugin-examples/dependency-graph/dashboard-view"; const exportName = "DependencyGraphDashboardView"; - const mod = await import("@fusion-plugin-examples/dependency-graph/dashboard-view") as Record>; + const mod = await import("@fusion-plugin-examples/dependency-graph/dashboard-view") as unknown as Record>; const component = mod[exportName]; if (!component) { console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`); @@ -28,7 +28,7 @@ async function loadDependencyGraphView(): Promise<{ default: PluginViewComponent async function loadRoadmapView(): Promise<{ default: PluginViewComponent }> { const moduleId = "@fusion-plugin-examples/roadmap/dashboard-view"; const exportName = "RoadmapDashboardView"; - const mod = await import("@fusion-plugin-examples/roadmap/dashboard-view") as Record>; + const mod = await import("@fusion-plugin-examples/roadmap/dashboard-view") as unknown as Record>; const component = mod[exportName]; if (!component) { console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`); diff --git a/packages/dashboard/src/__tests__/branch-selection.test.ts b/packages/dashboard/src/__tests__/branch-selection.test.ts new file mode 100644 index 000000000..5c0ee4856 --- /dev/null +++ b/packages/dashboard/src/__tests__/branch-selection.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + derivePerTaskBranch, + resolveBranchAssignmentContext, + resolveBranchSelection, +} from "../routes/branch-selection.js"; + +describe("branch-selection", () => { + it("resolves project-default and auto-new without branch", () => { + expect(resolveBranchSelection({ mode: "project-default", baseBranch: "main" }, undefined, undefined)).toEqual({ + branch: undefined, + baseBranch: "main", + }); + expect(resolveBranchSelection({ mode: "auto-new" }, undefined, "develop")).toEqual({ + branch: undefined, + baseBranch: undefined, + }); + }); + + it("requires branchName for existing/custom-new", () => { + expect(() => resolveBranchSelection({ mode: "existing" }, undefined, undefined)).toThrow( + "branchSelection.branchName is required", + ); + }); + + it("resolves assignment context", () => { + expect(resolveBranchAssignmentContext(undefined)).toEqual({ mode: "shared" }); + expect(resolveBranchAssignmentContext({ mode: "per-task-derived" })).toEqual({ mode: "per-task-derived" }); + expect(() => resolveBranchAssignmentContext({ mode: "bad" })).toThrow("branchAssignment.mode must be one of"); + }); + + it("derives a per-task branch suffix", () => { + expect(derivePerTaskBranch("feature/planning", "FN-123 add parser")).toBe("feature/planning/fn-123-add-parser"); + expect(derivePerTaskBranch(undefined, "FN-123")).toBeUndefined(); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index b9ae56105..21d13e833 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -1598,6 +1598,126 @@ describe("Planning Mode Routes", () => { ); }); + it("applies branchSelection when creating a planning task", async () => { + (store.createTask as ReturnType).mockResolvedValue({ + id: "FN-200", + description: "A task created from planning", + column: "triage", + dependencies: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + const startRes = await REQUEST( + buildApp(), + "POST", + "/api/planning/start", + JSON.stringify({ initialPlan: "Build a user auth system" }), + { "Content-Type": "application/json" } + ); + const sessionId = startRes.body.sessionId; + + await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" }); + await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" }); + await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { confirm: true } }), { "Content-Type": "application/json" }); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/planning/create-task", + JSON.stringify({ + sessionId, + branchSelection: { + mode: "existing", + branchName: "feature/shared-auth", + baseBranch: "develop", + }, + }), + { "Content-Type": "application/json" } + ); + + expect(res.status).toBe(201); + expect(store.createTask).toHaveBeenCalledWith( + expect.objectContaining({ + branch: "feature/shared-auth", + baseBranch: "develop", + }), + ); + }); + + it("applies shared branchSelection to all planning subtasks", async () => { + (store.createTask as ReturnType) + .mockResolvedValueOnce({ + id: "FN-201", + description: "First", + column: "triage", + dependencies: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }) + .mockResolvedValueOnce({ + id: "FN-202", + description: "Second", + column: "triage", + dependencies: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + (store.updateTask as ReturnType).mockResolvedValue({}); + (store.logEntry as ReturnType).mockResolvedValue(undefined); + + const startRes = await REQUEST( + buildApp(), + "POST", + "/api/planning/start", + JSON.stringify({ initialPlan: "Build a user auth system" }), + { "Content-Type": "application/json" } + ); + const planningSessionId = startRes.body.sessionId; + + await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" }); + await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" }); + await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" }); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/planning/create-tasks", + JSON.stringify({ + planningSessionId, + branchSelection: { mode: "custom-new", branchName: "feature/auth-slice", baseBranch: "main" }, + subtasks: [ + { + id: "subtask-1", + title: "Auth backend", + description: "Implement backend", + suggestedSize: "M", + priority: "urgent", + dependsOn: [], + }, + { + id: "subtask-2", + title: "Auth UI", + description: "Implement UI", + suggestedSize: "S", + dependsOn: ["subtask-1"], + }, + ], + }), + { "Content-Type": "application/json" } + ); + + expect(res.status).toBe(201); + expect(store.createTask).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ branch: "feature/auth-slice", baseBranch: "main" }), + ); + expect(store.createTask).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ branch: "feature/auth-slice", baseBranch: "main" }), + ); + }); + it("returns 400 if session is not complete", async () => { // Create a session but don't complete it const startRes = await REQUEST( diff --git a/packages/dashboard/src/__tests__/routes-tasks.test.ts b/packages/dashboard/src/__tests__/routes-tasks.test.ts index 0309b4c23..6fbc224eb 100644 --- a/packages/dashboard/src/__tests__/routes-tasks.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks.test.ts @@ -1909,6 +1909,77 @@ describe("POST /subtasks/*", () => { expect(store.updateTask).toHaveBeenCalledWith("FN-102", { dependencies: ["FN-101"] }); }); + it("applies explicit branch selection to created subtasks", async () => { + (store.createTask as ReturnType) + .mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-201", title: "First", column: "triage" }); + + const start = await REQUEST( + buildApp(), + "POST", + "/api/subtasks/start-streaming", + JSON.stringify({ description: "Break this feature into subtasks" }), + { "Content-Type": "application/json" }, + ); + + const createRes = await REQUEST( + buildApp(), + "POST", + "/api/subtasks/create-tasks", + JSON.stringify({ + sessionId: start.body.sessionId, + branchSelection: { mode: "custom-new", branchName: "feature/planning", baseBranch: "main" }, + subtasks: [ + { tempId: "subtask-1", title: "First", description: "Do first" }, + ], + }), + { "Content-Type": "application/json" }, + ); + + expect(createRes.status).toBe(201); + expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ + branch: "feature/planning", + baseBranch: "main", + })); + }); + + it("derives per-task branches when requested", async () => { + (store.createTask as ReturnType) + .mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-301", title: "First", column: "triage" }) + .mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-302", title: "Second", column: "triage" }); + + const start = await REQUEST( + buildApp(), + "POST", + "/api/subtasks/start-streaming", + JSON.stringify({ description: "Break this feature into subtasks" }), + { "Content-Type": "application/json" }, + ); + + const createRes = await REQUEST( + buildApp(), + "POST", + "/api/subtasks/create-tasks", + JSON.stringify({ + sessionId: start.body.sessionId, + branchSelection: { mode: "custom-new", branchName: "feature/planning" }, + branchAssignment: { mode: "per-task-derived" }, + subtasks: [ + { tempId: "subtask-1", title: "First Task", description: "Do first" }, + { tempId: "subtask-2", title: "Second Task", description: "Do second" }, + ], + }), + { "Content-Type": "application/json" }, + ); + + expect(createRes.status).toBe(201); + expect(store.createTask).toHaveBeenNthCalledWith(1, expect.objectContaining({ + branch: "feature/planning/first-task", + })); + expect(store.createTask).toHaveBeenNthCalledWith(2, expect.objectContaining({ + branch: "feature/planning/second-task", + })); + }); + it("returns 404 for invalid subtask session during batch creation", async () => { const res = await REQUEST( buildApp(), diff --git a/packages/dashboard/src/routes/branch-selection.ts b/packages/dashboard/src/routes/branch-selection.ts new file mode 100644 index 000000000..a7b16a00f --- /dev/null +++ b/packages/dashboard/src/routes/branch-selection.ts @@ -0,0 +1,127 @@ +import { badRequest } from "../api-error.js"; + +export type BranchSelectionMode = + | "project-default" + | "auto-new" + | "existing" + | "custom-new"; + +export interface BranchSelectionPayload { + mode?: unknown; + branchName?: unknown; + baseBranch?: unknown; +} + +export type PlanningBranchMode = "shared" | "per-task-derived"; + +export interface ResolvedBranchSelection { + branch?: string; + baseBranch?: string; +} + +export interface BranchAssignmentContext { + mode?: unknown; +} + +export interface ResolvedBranchAssignmentContext { + mode: PlanningBranchMode; +} + +function normalizeOptionalBranch(value: unknown, fieldName: string): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") { + throw badRequest(`${fieldName} must be a string`); + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function resolveBranchSelection( + selectionInput: unknown, + fallbackBranch: unknown, + fallbackBaseBranch: unknown, +): ResolvedBranchSelection { + const fallback = { + branch: normalizeOptionalBranch(fallbackBranch, "branch"), + baseBranch: normalizeOptionalBranch(fallbackBaseBranch, "baseBranch"), + }; + + if (selectionInput === undefined || selectionInput === null) { + return fallback; + } + + if (typeof selectionInput !== "object" || Array.isArray(selectionInput)) { + throw badRequest("branchSelection must be an object"); + } + + const selection = selectionInput as BranchSelectionPayload; + const mode = typeof selection.mode === "string" ? selection.mode : undefined; + if (!mode) { + throw badRequest("branchSelection.mode is required"); + } + + if (![ + "project-default", + "auto-new", + "existing", + "custom-new", + ].includes(mode)) { + throw badRequest("branchSelection.mode must be one of: project-default, auto-new, existing, custom-new"); + } + + const baseBranch = normalizeOptionalBranch(selection.baseBranch, "branchSelection.baseBranch"); + + if (mode === "project-default") { + return { branch: undefined, baseBranch }; + } + + if (mode === "auto-new") { + // Auto-named branch is derived later by existing task-id based flow. + return { branch: undefined, baseBranch }; + } + + const branchName = normalizeOptionalBranch(selection.branchName, "branchSelection.branchName"); + if (!branchName) { + throw badRequest("branchSelection.branchName is required for existing/custom-new modes"); + } + + return { + branch: branchName, + baseBranch, + }; +} + +export function resolveBranchAssignmentContext(input: unknown): ResolvedBranchAssignmentContext { + if (input === undefined || input === null) { + return { mode: "shared" }; + } + if (typeof input !== "object" || Array.isArray(input)) { + throw badRequest("branchAssignment must be an object"); + } + const payload = input as BranchAssignmentContext; + const mode = payload.mode; + if (mode !== undefined && mode !== "shared" && mode !== "per-task-derived") { + throw badRequest("branchAssignment.mode must be one of: shared, per-task-derived"); + } + return { + mode: mode === "per-task-derived" ? "per-task-derived" : "shared", + }; +} + +function sanitizeSegment(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9._/-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^[-/.]+|[-/.]+$/g, "") + .slice(0, 48); +} + +export function derivePerTaskBranch(sharedBranch: string | undefined, taskSegment: string): string | undefined { + const base = normalizeOptionalBranch(sharedBranch, "sharedBranch"); + if (!base) return undefined; + const segment = sanitizeSegment(taskSegment); + if (!segment) return base; + return `${base}/${segment}`; +} diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 2146c158e..e541f7f2f 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -10,6 +10,7 @@ import { ApiError, badRequest, notFound, rateLimited } from "../api-error.js"; import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js"; import type { AiSessionStore } from "../ai-session-store.js"; import type { ApiRoutesContext } from "./types.js"; +import { derivePerTaskBranch, resolveBranchAssignmentContext, resolveBranchSelection } from "./branch-selection.js"; interface PlanningSubtaskRouteDeps { store: TaskStore; @@ -170,10 +171,14 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann router.post("/subtasks/create-tasks", async (req, res) => { try { - const { sessionId, subtasks, parentTaskId } = req.body as { + const { sessionId, subtasks, parentTaskId, branch, baseBranch, branchSelection, branchAssignment } = req.body as { sessionId?: string; subtasks?: Array<{ tempId: string; title: string; description: string; size?: "S" | "M" | "L"; dependsOn?: string[] }>; parentTaskId?: string; + branch?: unknown; + baseBranch?: unknown; + branchSelection?: unknown; + branchAssignment?: unknown; }; if (!sessionId || typeof sessionId !== "string") { @@ -202,6 +207,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann } } + const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } = + resolveBranchSelection(branchSelection, branch, baseBranch); + const { mode: branchMode } = resolveBranchAssignmentContext(branchAssignment); + const createdTasks = [] as Awaited>[]; const tempIdToTaskId = new Map(); @@ -210,6 +219,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann throw badRequest("Each subtask must include tempId and title"); } + const taskBranch = branchMode === "per-task-derived" + ? derivePerTaskBranch(resolvedBranch, item.title || item.tempId) + : resolvedBranch; + const task = await scopedStore.createTask({ title: item.title.trim(), description: typeof item.description === "string" ? item.description.trim() : item.title.trim(), @@ -221,6 +234,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann validatorModelProvider: parentTask?.validatorModelProvider, validatorModelId: parentTask?.validatorModelId, source: { sourceType: "api", sourceParentTaskId: typeof parentTaskId === "string" ? parentTaskId : undefined }, + branch: taskBranch, + baseBranch: resolvedBaseBranch, }); tempIdToTaskId.set(item.tempId, task.id); @@ -941,9 +956,12 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann */ router.post("/planning/create-task", async (req, res) => { try { - const { sessionId, summary: summaryInput } = req.body as { + const { sessionId, summary: summaryInput, branch, baseBranch, branchSelection } = req.body as { sessionId?: unknown; summary?: unknown; + branch?: unknown; + baseBranch?: unknown; + branchSelection?: unknown; }; if (!sessionId || typeof sessionId !== "string") { @@ -1037,6 +1055,9 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann throw badRequest("Planning session is not complete"); } + const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } = + resolveBranchSelection(branchSelection, branch, baseBranch); + // Create the task const task = await scopedStore.createTask({ title: summary.title, @@ -1045,6 +1066,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann dependencies: summary.suggestedDependencies.length > 0 ? summary.suggestedDependencies : undefined, priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY, source: { sourceType: "api" }, + branch: resolvedBranch, + baseBranch: resolvedBaseBranch, }); // Update task with suggested size if provided @@ -1129,7 +1152,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann */ router.post("/planning/create-tasks", async (req, res) => { try { - const { planningSessionId, subtasks } = req.body as { + const { planningSessionId, subtasks, branch, baseBranch, branchSelection, branchAssignment } = req.body as { planningSessionId?: string; subtasks?: Array<{ id: string; @@ -1139,6 +1162,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann priority?: TaskPriority; dependsOn: string[]; }>; + branch?: unknown; + baseBranch?: unknown; + branchSelection?: unknown; + branchAssignment?: unknown; }; if (!planningSessionId || typeof planningSessionId !== "string") { @@ -1176,11 +1203,19 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann } } + const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } = + resolveBranchSelection(branchSelection, branch, baseBranch); + const { mode: branchMode } = resolveBranchAssignmentContext(branchAssignment); + const createdTasks = [] as Awaited>[]; const tempIdToTaskId = new Map(); // Create tasks for (const item of subtasks) { + const taskBranch = branchMode === "per-task-derived" + ? derivePerTaskBranch(resolvedBranch, item.title || item.id) + : resolvedBranch; + const task = await scopedStore.createTask({ title: item.title.trim(), description: typeof item.description === "string" ? item.description.trim() : item.title.trim(), @@ -1188,6 +1223,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann dependencies: undefined, priority: isTaskPriority(item.priority) ? item.priority : DEFAULT_TASK_PRIORITY, source: { sourceType: "api", sourceMetadata: { planningSessionId } }, + branch: taskBranch, + baseBranch: resolvedBaseBranch, }); tempIdToTaskId.set(item.id, task.id); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 62a3dd3f4..37e2b9efe 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -19,6 +19,7 @@ import { planTaskWorktreePath } from "@fusion/engine"; import { ApiError, badRequest, conflict, notFound } from "../api-error.js"; import { fetchFromRemoteNode } from "./register-settings-sync-helpers.js"; import type { ApiRoutesContext } from "./types.js"; +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; @@ -180,6 +181,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork source, branch, baseBranch, + branchSelection, nodeId, } = req.body; if (!description || typeof description !== "string") { @@ -274,17 +276,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork ? source : { sourceType: "api" as const }; - const validateOptionalBranchString = (value: unknown, fieldName: string): string | undefined => { - if (value === undefined || value === null) return undefined; - if (typeof value !== "string") { - throw badRequest(`${fieldName} must be a string`); - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - }; - - const normalizedBranch = validateOptionalBranchString(branch, "branch"); - const normalizedBaseBranch = validateOptionalBranchString(baseBranch, "baseBranch"); + const { branch: normalizedBranch, baseBranch: normalizedBaseBranch } = + resolveBranchSelection(branchSelection, branch, baseBranch); const createInput = { title,