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:
@@ -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 } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
36
packages/dashboard/src/__tests__/branch-selection.test.ts
Normal file
36
packages/dashboard/src/__tests__/branch-selection.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
127
packages/dashboard/src/routes/branch-selection.ts
Normal file
127
packages/dashboard/src/routes/branch-selection.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user