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.
|
||||
|
||||
> **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
|
||||
|
||||
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 () => {
|
||||
const store = createMockStore();
|
||||
process.env.FN3968_SCRIPT_ENV = "workflow-script-env";
|
||||
|
||||
@@ -157,7 +157,14 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
if (!shouldHydrateStoredCredential(current, credential)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5434,6 +5434,26 @@ ${failureFeedback}
|
||||
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}'`)) {
|
||||
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.
|
||||
* Returns structured outcome with support for revision requests.
|
||||
|
||||
Reference in New Issue
Block a user