feat(FN-4104): wrap workflow result headers and warn on overlapping in-prog
Adds warning logic when overlapping in-progress step updates occur, and wraps workflow result table headers with proper text handling and styling in the dashboard UI. Fusion-Task-Id: FN-4104
This commit is contained in:
@@ -567,6 +567,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
|
|||||||
- Writes are idempotent: scheduler updates `status/blockedBy` only when values change, reducing per-tick churn and audit noise.
|
- Writes are idempotent: scheduler updates `status/blockedBy` only when values change, reducing per-tick churn and audit noise.
|
||||||
- Self-healing remains responsible for terminal/missing blocker cleanup (`clearStaleBlockedBy()`), while scheduler overlap stamping now focuses on stable active-overlap attribution.
|
- Self-healing remains responsible for terminal/missing blocker cleanup (`clearStaleBlockedBy()`), while scheduler overlap stamping now focuses on stable active-overlap attribution.
|
||||||
- `StepSessionExecutor` (`step-session-executor.ts`) — per-step sessions + parallel wave execution
|
- `StepSessionExecutor` (`step-session-executor.ts`) — per-step sessions + parallel wave execution
|
||||||
|
- `createTaskUpdateTool()` (`executor.ts`) emits a diagnostic warning when an agent marks step N `in-progress` while another step on the same task is already `in-progress`; the update still proceeds so operators get evidence without changing task semantics.
|
||||||
- `TaskCompletion` (`task-completion.ts`) — completion gate helpers
|
- `TaskCompletion` (`task-completion.ts`) — completion gate helpers
|
||||||
- `SpecStaleness` (`spec-staleness.ts`) — stale spec detection utilities
|
- `SpecStaleness` (`spec-staleness.ts`) — stale spec detection utilities
|
||||||
- `MissionExecutionLoop` (`mission-execution-loop.ts`) — validator/fix loop orchestration
|
- `MissionExecutionLoop` (`mission-execution-loop.ts`) — validator/fix loop orchestration
|
||||||
|
|||||||
@@ -85,18 +85,27 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-configured-title-row {
|
.workflow-configured-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
flex: 1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-configured-title-row h4 {
|
.workflow-configured-title-row h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
|
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-configured-header .workflow-results-edit-toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-configured-count {
|
.workflow-configured-count {
|
||||||
@@ -126,11 +135,19 @@
|
|||||||
.workflow-configured-name {
|
.workflow-configured-name {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
min-width: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
|
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-configured-name-text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-configured-description {
|
.workflow-configured-description {
|
||||||
margin: var(--space-xs) 0 0;
|
margin: var(--space-xs) 0 0;
|
||||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||||
@@ -374,18 +391,25 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
padding: var(--space-md) var(--space-lg);
|
padding: var(--space-md) var(--space-lg);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-output-modal-title {
|
.workflow-output-modal-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
flex: 1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-output-modal-name {
|
.workflow-output-modal-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
|
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -395,6 +419,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-output-modal-close {
|
.workflow-output-modal-close {
|
||||||
@@ -478,6 +504,28 @@
|
|||||||
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-configured-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-configured-header .workflow-results-edit-toggle {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-output-modal {
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-output-modal-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-output-modal-controls {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-result-expand-toggle {
|
.workflow-result-expand-toggle {
|
||||||
min-width: calc(var(--space-lg) * 2 + var(--space-xs));
|
min-width: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
|
|||||||
@@ -615,7 +615,7 @@ export function WorkflowResultsTab({
|
|||||||
data-testid={`workflow-configured-step-${step.id}`}
|
data-testid={`workflow-configured-step-${step.id}`}
|
||||||
>
|
>
|
||||||
<div className="workflow-configured-name">
|
<div className="workflow-configured-name">
|
||||||
{step.name}
|
<span className="workflow-configured-name-text">{step.name}</span>
|
||||||
{phaseBadge(step.phase, step.id, "workflow-configured-phase")}
|
{phaseBadge(step.phase, step.id, "workflow-configured-phase")}
|
||||||
</div>
|
</div>
|
||||||
<p className="workflow-configured-description">{step.description}</p>
|
<p className="workflow-configured-description">{step.description}</p>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
|||||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||||
import { WorkflowResultsTab } from "../WorkflowResultsTab";
|
import { WorkflowResultsTab } from "../WorkflowResultsTab";
|
||||||
import { fetchWorkflowSteps } from "../../api";
|
import { fetchWorkflowSteps } from "../../api";
|
||||||
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
||||||
import type { WorkflowStep, WorkflowStepResult } from "@fusion/core";
|
import type { WorkflowStep, WorkflowStepResult } from "@fusion/core";
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
@@ -818,6 +818,24 @@ describe("WorkflowResultsTab", () => {
|
|||||||
expect(match?.[1] ?? "").not.toMatch(/#[0-9a-fA-F]{3,8}|rgba?\(/);
|
expect(match?.[1] ?? "").not.toMatch(/#[0-9a-fA-F]{3,8}|rgba?\(/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("wraps configured workflow names and modal headers to prevent long-name overflow", () => {
|
||||||
|
const css = loadAllAppCssBaseOnly();
|
||||||
|
|
||||||
|
expect(css).toMatch(/\.workflow-configured-title-row\s*\{[^}]*flex-wrap:\s*wrap;[^}]*min-width:\s*0;/);
|
||||||
|
expect(css).toMatch(/\.workflow-configured-name\s*\{[^}]*flex-wrap:\s*wrap;[^}]*min-width:\s*0;/);
|
||||||
|
expect(css).toMatch(/\.workflow-configured-name-text\s*\{[^}]*min-width:\s*0;[^}]*overflow-wrap:\s*anywhere;/);
|
||||||
|
expect(css).toMatch(/\.workflow-output-modal-header\s*\{[^}]*flex-wrap:\s*wrap;/);
|
||||||
|
expect(css).toMatch(/\.workflow-output-modal-title\s*\{[^}]*flex-wrap:\s*wrap;[^}]*min-width:\s*0;/);
|
||||||
|
expect(css).toMatch(/\.workflow-output-modal-name\s*\{[^}]*min-width:\s*0;[^}]*overflow-wrap:\s*anywhere;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows workflow modal controls to wrap on mobile so the close button stays visible", () => {
|
||||||
|
const css = loadAllAppCss();
|
||||||
|
|
||||||
|
expect(css).toMatch(/@media \(max-width: 768px\)\s*\{[\s\S]*?\.workflow-output-modal-controls\s*\{[^}]*width:\s*100%;[^}]*justify-content:\s*space-between;/);
|
||||||
|
expect(css).toMatch(/@media \(max-width: 768px\)\s*\{[\s\S]*?\.workflow-configured-header \.workflow-results-edit-toggle\s*\{[^}]*width:\s*100%;[^}]*justify-content:\s*center;/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("expanded view modal", () => {
|
describe("expanded view modal", () => {
|
||||||
|
|||||||
@@ -1271,6 +1271,44 @@ describe("E2E review pipeline — multi-verdict sequence", () => {
|
|||||||
mockedExistsSync.mockReturnValue(true);
|
mockedExistsSync.mockReturnValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("warns when fn_task_update marks a second step in-progress", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
store.getTask.mockResolvedValue({
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n### Step 1: Implement\n### Step 2: Verify",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
steps: [
|
||||||
|
{ name: "Preflight", status: "in-progress" },
|
||||||
|
{ name: "Implement", status: "pending" },
|
||||||
|
{ name: "Verify", status: "pending" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) => ({
|
||||||
|
steps: [
|
||||||
|
{ name: "Preflight", status: "in-progress" },
|
||||||
|
{ name: "Implement", status: step === 1 ? status : "pending" },
|
||||||
|
{ name: "Verify", status: "pending" },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { tools } = await captureE2ETools(store);
|
||||||
|
const result = await tools.fn_task_update("u-warn", { step: 2, status: "in-progress" });
|
||||||
|
|
||||||
|
expect(executorLog.warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(executorLog.warn).toHaveBeenCalledWith(
|
||||||
|
"FN-E2E: fn_task_update marking step 2 in-progress while step 1 is already in-progress",
|
||||||
|
);
|
||||||
|
expect(store.updateStep).toHaveBeenCalledWith("FN-E2E", 1, "in-progress");
|
||||||
|
expect(result.content[0].text).toContain("Step 2 (Implement) → in-progress");
|
||||||
|
});
|
||||||
|
|
||||||
it("full sequence: plan APPROVE → code REVISE (blocked) → code APPROVE (unblocked) → done", async () => {
|
it("full sequence: plan APPROVE → code REVISE (blocked) → code APPROVE (unblocked) → done", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
store.updateStep.mockImplementation(async (_id: string, step: number, status: string) =>
|
||||||
|
|||||||
@@ -4296,6 +4296,22 @@ export class TaskExecutor {
|
|||||||
|
|
||||||
const stepIndex = step - 1;
|
const stepIndex = step - 1;
|
||||||
|
|
||||||
|
if (status === "in-progress") {
|
||||||
|
try {
|
||||||
|
const latestTask = await store.getTask(taskId);
|
||||||
|
const otherInProgressStepIndex = latestTask.steps.findIndex(
|
||||||
|
(taskStep, index) => index !== stepIndex && taskStep.status === "in-progress",
|
||||||
|
);
|
||||||
|
if (otherInProgressStepIndex !== -1) {
|
||||||
|
executorLog.warn(
|
||||||
|
`${taskId}: fn_task_update marking step ${step} in-progress while step ${otherInProgressStepIndex + 1} is already in-progress`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
executorLog.warn(`${taskId}: failed to inspect step lease state before fn_task_update: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Enforce code review REVISE: block advancing to "done" when the last
|
// Enforce code review REVISE: block advancing to "done" when the last
|
||||||
// code review for this step returned REVISE. The agent must fix the
|
// code review for this step returned REVISE. The agent must fix the
|
||||||
// issues and call fn_review_step(type="code") again before proceeding.
|
// issues and call fn_review_step(type="code") again before proceeding.
|
||||||
|
|||||||
Reference in New Issue
Block a user