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
This commit is contained in:
Fusion
2026-05-09 22:06:46 -07:00
committed by gsxdsm
parent 4051dabe4d
commit e7ee12640f
9 changed files with 480 additions and 21 deletions

View File

@@ -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<MissionWithHierarchy> {
return api<MissionWithHierarchy>(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 } : {}),
}),
});
}

View File

@@ -98,6 +98,10 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
const [localDescription, setLocalDescription] = useState(initialDescription);
const [error, setError] = useState<string | null>(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<string | null>(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
</div>
<div className="planning-summary-form">
<div className="task-detail-section">
<div className="form-group">
<label>Branch strategy</label>
<select value={branchMode} onChange={(event) => setBranchMode(event.target.value as typeof branchMode)} disabled={view.type === "creating"}>
<option value="project-default">Use project/default branch</option>
<option value="auto-new">Create auto-named branch per task</option>
<option value="existing">Use existing branch</option>
<option value="custom-new">Create custom new branch</option>
</select>
</div>
{(branchMode === "existing" || branchMode === "custom-new") && (
<div className="form-group">
<label>Branch name</label>
<input value={branchName} onChange={(event) => setBranchName(event.target.value)} disabled={view.type === "creating"} />
</div>
)}
<div className="form-group">
<label>Merge target / base branch (optional)</label>
<input value={baseBranch} onChange={(event) => setBaseBranch(event.target.value)} disabled={view.type === "creating"} placeholder="main" />
</div>
<div className="form-group">
<label>Planning branch mode</label>
<select value={branchAssignmentMode} onChange={(event) => setBranchAssignmentMode(event.target.value as typeof branchAssignmentMode)} disabled={view.type === "creating"}>
<option value="shared">Shared branch for all subtasks</option>
<option value="per-task-derived">Per-task branch derived from planning branch</option>
</select>
</div>
</div>
{subtasks.map((subtask, index) => {
const isDragging = draggingId === subtask.id;
const isDragOver = dragOverId === subtask.id;

View File

@@ -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<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
const mod = await import("@fusion-plugin-examples/dependency-graph/dashboard-view") as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
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<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
const mod = await import("@fusion-plugin-examples/roadmap/dashboard-view") as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
const component = mod[exportName];
if (!component) {
console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`);

View File

@@ -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();
});
});

View File

@@ -1598,6 +1598,126 @@ describe("Planning Mode Routes", () => {
);
});
it("applies branchSelection when creating a planning task", async () => {
(store.createTask as ReturnType<typeof vi.fn>).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<typeof vi.fn>)
.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<typeof vi.fn>).mockResolvedValue({});
(store.logEntry as ReturnType<typeof vi.fn>).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(

View File

@@ -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<typeof vi.fn>)
.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<typeof vi.fn>)
.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(),

View File

@@ -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}`;
}

View File

@@ -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<ReturnType<TaskStore["createTask"]>>[];
const tempIdToTaskId = new Map<string, string>();
@@ -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<ReturnType<TaskStore["createTask"]>>[];
const tempIdToTaskId = new Map<string, string>();
// 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);

View File

@@ -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,