feat(FN-3906): add frontend scope helpers for workflow step auto-skip
Adds frontend-UX auto-skip behavior for workflow steps in the executor, with tests covering the flow and documentation of the recovery logic. A changeset is included for this patch release. Fusion-Task-Id: FN-3906 Fusion-Task-Lineage: 3c50b82b-dd03-4ba3-8d77-5aaabb8734da
This commit is contained in:
5
.changeset/FN-3906-frontend-ux-autoskip.md
Normal file
5
.changeset/FN-3906-frontend-ux-autoskip.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix(FN-3906): auto-skip the built-in Frontend UX Design pre-merge workflow step when the task diff scope has no frontend/UI files, so non-frontend tasks no longer get stuck behind paused completion handoff deferrals for an irrelevant review gate.
|
||||||
@@ -49,6 +49,8 @@ The **Browser Verification** template uses browser automation style checks and i
|
|||||||
|
|
||||||
The **Frontend UX Design** template verifies visual polish and consistency with existing UI patterns and design tokens, including visual hierarchy, spacing/typography consistency, color/token consistency, component reuse, responsive behavior, and fit with existing design language.
|
The **Frontend UX Design** template verifies visual polish and consistency with existing UI patterns and design tokens, including visual hierarchy, spacing/typography consistency, color/token consistency, component reuse, responsive behavior, and fit with existing design language.
|
||||||
|
|
||||||
|
> **FN-3906 auto-skip behavior:** The pre-merge orchestrator now auto-skips the built-in `frontend-ux-design` step before pause/defer checks when the task diff scope has no frontend/UI files. Scope relevance includes extensions (`.tsx`, `.jsx`, `.vue`, `.svelte`, `.astro`, `.html`, `.css`, `.scss`, `.sass`, `.less`, `.styl`), common UI path segments (`/components/`, `/app/components/`, `/dashboard/`, `/frontend/`, `/ui/`, `/styles/`, `/themes/`, `/design-system/`, `/design-tokens/`), and token/theme filenames (`tokens.(ts|js|json|css)`, `theme.(ts|js|json|css)`). If scope capture is uncertain (error/empty list), the step falls back to normal execution and relies on the agent-side FAST-BAIL rule.
|
||||||
|
|
||||||
## Plugin-Contributed Steps
|
## Plugin-Contributed Steps
|
||||||
|
|
||||||
Installed plugins can also provide **workflow step templates** that you enable from **Settings → Workflow Steps**, just like Fusion’s built-in quality gates.
|
Installed plugins can also provide **workflow step templates** that you enable from **Settings → Workflow Steps**, just like Fusion’s built-in quality gates.
|
||||||
|
|||||||
@@ -815,6 +815,212 @@ describe("Workflow Steps Execution", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("auto-skips built-in Frontend UX Design when diff scope has no frontend files", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const task = {
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress" as const,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Preflight", status: "done" as const }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["frontend-ux-design"],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
store.getTask.mockResolvedValue(task as any);
|
||||||
|
store.getWorkflowStep.mockResolvedValue({
|
||||||
|
id: "frontend-ux-design",
|
||||||
|
name: "Frontend UX Design",
|
||||||
|
description: "UI review",
|
||||||
|
prompt: "Review UI",
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||||
|
if (typeof cmd === "string" && cmd.includes("git merge-base HEAD origin/main")) {
|
||||||
|
return Buffer.from("abc123\n");
|
||||||
|
}
|
||||||
|
if (typeof cmd === "string" && cmd.includes("git diff --name-only abc123..HEAD")) {
|
||||||
|
return Buffer.from("packages/engine/src/foo.ts\n");
|
||||||
|
}
|
||||||
|
return Buffer.from("");
|
||||||
|
});
|
||||||
|
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||||||
|
const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" });
|
||||||
|
|
||||||
|
const result = await (executor as any).runWorkflowSteps(task, "/tmp/test", {});
|
||||||
|
|
||||||
|
expect(result).toEqual({ allPassed: true });
|
||||||
|
expect(executeStepSpy).not.toHaveBeenCalled();
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith(
|
||||||
|
"FN-001",
|
||||||
|
expect.objectContaining({
|
||||||
|
workflowStepResults: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
workflowStepId: "frontend-ux-design",
|
||||||
|
status: "skipped",
|
||||||
|
output: expect.stringContaining("No frontend/UI files in diff scope"),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"FN-001",
|
||||||
|
"[pre-merge] Auto-skipped Frontend UX Design — no frontend/UI files in diff scope",
|
||||||
|
);
|
||||||
|
const logged = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
|
||||||
|
expect(logged.some((line: string) => line.includes("Completion handoff deferred"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs built-in Frontend UX Design normally when UI files are in scope", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const task = {
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress" as const,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Preflight", status: "done" as const }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["frontend-ux-design"],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
store.getTask.mockResolvedValue(task as any);
|
||||||
|
store.getWorkflowStep.mockResolvedValue({
|
||||||
|
id: "frontend-ux-design",
|
||||||
|
name: "Frontend UX Design",
|
||||||
|
description: "UI review",
|
||||||
|
prompt: "Review UI",
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||||
|
if (typeof cmd === "string" && cmd.includes("git merge-base HEAD origin/main")) {
|
||||||
|
return Buffer.from("abc123\n");
|
||||||
|
}
|
||||||
|
if (typeof cmd === "string" && cmd.includes("git diff --name-only abc123..HEAD")) {
|
||||||
|
return Buffer.from("packages/dashboard/app/components/Foo.tsx\n");
|
||||||
|
}
|
||||||
|
return Buffer.from("");
|
||||||
|
});
|
||||||
|
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||||||
|
const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "approved" });
|
||||||
|
|
||||||
|
const result = await (executor as any).runWorkflowSteps(task, "/tmp/test", {});
|
||||||
|
|
||||||
|
expect(result).toEqual({ allPassed: true });
|
||||||
|
expect(executeStepSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith(
|
||||||
|
"FN-001",
|
||||||
|
expect.objectContaining({
|
||||||
|
workflowStepResults: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ workflowStepId: "frontend-ux-design", status: "passed" }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const logged = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
|
||||||
|
expect(logged.some((line: string) => line.includes("Auto-skipped Frontend UX Design"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids paused defer for built-in Frontend UX Design when no UI files are in scope", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const pausedTask = {
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress" as const,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Preflight", status: "done" as const }],
|
||||||
|
currentStep: 0,
|
||||||
|
paused: true,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["frontend-ux-design"],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
store.getTask.mockResolvedValue(pausedTask as any);
|
||||||
|
store.getWorkflowStep.mockResolvedValue({
|
||||||
|
id: "frontend-ux-design",
|
||||||
|
name: "Frontend UX Design",
|
||||||
|
description: "UI review",
|
||||||
|
prompt: "Review UI",
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||||
|
if (typeof cmd === "string" && cmd.includes("git merge-base HEAD origin/main")) {
|
||||||
|
return Buffer.from("abc123\n");
|
||||||
|
}
|
||||||
|
if (typeof cmd === "string" && cmd.includes("git diff --name-only abc123..HEAD")) {
|
||||||
|
return Buffer.from("packages/engine/src/foo.ts\n");
|
||||||
|
}
|
||||||
|
return Buffer.from("");
|
||||||
|
});
|
||||||
|
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||||||
|
const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" });
|
||||||
|
|
||||||
|
const result = await (executor as any).runWorkflowSteps(pausedTask, "/tmp/test", {});
|
||||||
|
|
||||||
|
expect(result).toEqual({ allPassed: true });
|
||||||
|
expect(executeStepSpy).not.toHaveBeenCalled();
|
||||||
|
const logged = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
|
||||||
|
expect(logged.some((line: string) => line.includes("Auto-skipped Frontend UX Design — no frontend/UI files in diff scope"))).toBe(true);
|
||||||
|
expect(logged.some((line: string) => line.includes("Completion handoff deferred — task paused (before workflow step 'Frontend UX Design')"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-skip custom step id even when named Frontend UX Design", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const task = {
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress" as const,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Preflight", status: "done" as const }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["ws-custom-1"],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
store.getTask.mockResolvedValue(task as any);
|
||||||
|
store.getWorkflowStep.mockResolvedValue({
|
||||||
|
id: "ws-custom-1",
|
||||||
|
name: "Frontend UX Design",
|
||||||
|
description: "Custom UI review",
|
||||||
|
prompt: "Review UI",
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||||||
|
const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" });
|
||||||
|
|
||||||
|
const result = await (executor as any).runWorkflowSteps(task, "/tmp/test", {});
|
||||||
|
|
||||||
|
expect(result).toEqual({ allPassed: true });
|
||||||
|
expect(executeStepSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("executes script-mode workflow step successfully", async () => {
|
it("executes script-mode workflow step successfully", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
process.env.FN3968_SCRIPT_ENV = "workflow-script-env";
|
process.env.FN3968_SCRIPT_ENV = "workflow-script-env";
|
||||||
|
|||||||
@@ -157,7 +157,14 @@ export function createFusionAuthStorage(): AuthStorage {
|
|||||||
if (!shouldHydrateStoredCredential(current, credential)) {
|
if (!shouldHydrateStoredCredential(current, credential)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (credential.type === "oauth" || credential.type === "api_key") {
|
if (credential.type === "oauth") {
|
||||||
|
if (typeof credential.expires !== "number" || Date.now() >= credential.expires) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
primary.set(provider, credential as AuthCredential);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (credential.type === "api_key") {
|
||||||
primary.set(provider, credential as AuthCredential);
|
primary.set(provider, credential as AuthCredential);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5434,6 +5434,26 @@ ${failureFeedback}
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isFrontendUxStep(ws)) {
|
||||||
|
try {
|
||||||
|
const scopedFiles = await this.captureModifiedFiles(worktreePath, currentTask.baseCommitSha);
|
||||||
|
if (scopedFiles.length > 0 && !this.hasFrontendFilesInScope(scopedFiles)) {
|
||||||
|
results.push({
|
||||||
|
workflowStepId: ws.id,
|
||||||
|
workflowStepName: ws.name,
|
||||||
|
phase: stepPhase,
|
||||||
|
status: "skipped",
|
||||||
|
output: "No frontend/UI files in diff scope — auto-skipped (FN-3906)",
|
||||||
|
});
|
||||||
|
await this.store.updateTask(task.id, { workflowStepResults: results });
|
||||||
|
await this.store.logEntry(task.id, "[pre-merge] Auto-skipped Frontend UX Design — no frontend/UI files in diff scope");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort scope detection only; fall through to regular execution/defer flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (await this.shouldDeferWorkflowStepCompletion(task.id, `before workflow step '${ws.name}'`)) {
|
if (await this.shouldDeferWorkflowStepCompletion(task.id, `before workflow step '${ws.name}'`)) {
|
||||||
return "deferred-paused";
|
return "deferred-paused";
|
||||||
}
|
}
|
||||||
@@ -5615,6 +5635,42 @@ ${failureFeedback}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FN-3906: Only the built-in Frontend UX Design step gets orchestrator-level
|
||||||
|
* diff-scope auto-skip. Match by canonical template id only.
|
||||||
|
*/
|
||||||
|
private isFrontendUxStep(workflowStep: WorkflowStep): boolean {
|
||||||
|
return workflowStep.id === "frontend-ux-design";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FN-3906: Detect whether the task diff scope contains frontend/UI-related
|
||||||
|
* files so Frontend UX Design can be safely skipped when irrelevant.
|
||||||
|
*/
|
||||||
|
private hasFrontendFilesInScope(files: string[]): boolean {
|
||||||
|
const frontendExtensionPattern = /\.(tsx|jsx|vue|svelte|astro|html|css|scss|sass|less|styl)$/i;
|
||||||
|
const frontendPathMarkers = [
|
||||||
|
"/components/",
|
||||||
|
"/app/components/",
|
||||||
|
"/dashboard/",
|
||||||
|
"/frontend/",
|
||||||
|
"/ui/",
|
||||||
|
"/styles/",
|
||||||
|
"/themes/",
|
||||||
|
"/design-system/",
|
||||||
|
"/design-tokens/",
|
||||||
|
];
|
||||||
|
const frontendTokenFilenamePattern = /(^|\/)(tokens|theme)\.(ts|js|json|css)$/i;
|
||||||
|
|
||||||
|
return files.some((file) => {
|
||||||
|
const normalized = file.replace(/\\/g, "/");
|
||||||
|
const lowered = normalized.toLowerCase();
|
||||||
|
return frontendExtensionPattern.test(normalized)
|
||||||
|
|| frontendPathMarkers.some((marker) => lowered.includes(marker))
|
||||||
|
|| frontendTokenFilenamePattern.test(lowered);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a single workflow step by spawning an agent with the step's prompt.
|
* Execute a single workflow step by spawning an agent with the step's prompt.
|
||||||
* Returns structured outcome with support for revision requests.
|
* Returns structured outcome with support for revision requests.
|
||||||
|
|||||||
Reference in New Issue
Block a user