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:
Fusion
2026-05-11 11:08:02 -07:00
committed by gsxdsm
parent 80130966f8
commit c41d49f28b
5 changed files with 277 additions and 1 deletions

View 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.

View File

@@ -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 Fusions built-in quality gates.

View File

@@ -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";

View File

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

View File

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