feat(FN-7039): drive progress bar + Workflow tab from graph-written results
getUnifiedTaskProgress reads task.workflowStepResults (graph-written in U2) and no longer needs workflowStepNameLookup — names come from result.workflowStepName. Removed the lookup prop + fetchWorkflowSteps-name plumbing end-to-end (App, MainContent, RightDock, Board, Column, Lane, WorktreeGroup, TaskCard). Added the running state (pending + startedAt) and a distinct advisory_failure (amber, non- blocking) vs failed (red) treatment; advisory_failure counts as complete. Plan U3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -793,11 +793,6 @@ function AppInner() {
|
||||
};
|
||||
}, [currentProject?.id]);
|
||||
|
||||
const workflowStepNameLookup = useMemo(
|
||||
() => new Map(workflowSteps.map((step) => [step.id, step.name] as const)),
|
||||
[workflowSteps],
|
||||
);
|
||||
|
||||
// History-aware modal open handlers — push nav entries for back-navigation.
|
||||
const openDetailTask = useCallback((task: Task | TaskDetail, tab?: Parameters<typeof modalManager.openDetailTask>[1], opts?: { origin?: DetailTaskOrigin }) => {
|
||||
modalManager.openDetailTask(task, tab, opts);
|
||||
@@ -1169,7 +1164,6 @@ function AppInner() {
|
||||
subscribePluginEvents,
|
||||
openDetailTask,
|
||||
openFileInBrowser,
|
||||
workflowStepNameLookup,
|
||||
prAuthAvailable,
|
||||
autoMerge,
|
||||
settingsLoaded,
|
||||
@@ -1326,7 +1320,7 @@ function AppInner() {
|
||||
markGitHubStarPromptShown,
|
||||
setShowGitHubStarPrompt,
|
||||
};
|
||||
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openFileInBrowser, openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), workflowStepNameLookup, prAuthAvailable, autoMerge, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
|
||||
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openFileInBrowser, openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
|
||||
|
||||
return (
|
||||
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
|
||||
|
||||
@@ -7,7 +7,7 @@ import "./Board.css";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { fetchWorkflowSteps, promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api";
|
||||
import { promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api";
|
||||
import { useBlockerFanout } from "../hooks/useBlockerFanout";
|
||||
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
@@ -89,7 +89,6 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
||||
return previous.every((task, index) => task === next[index]);
|
||||
}
|
||||
|
||||
const EMPTY_WORKFLOW_STEP_NAME_LOOKUP: ReadonlyMap<string, string> = new Map();
|
||||
let boardWasPreviouslyInactive = false;
|
||||
|
||||
// Real mobile browsers can pan the document horizontally while focusing/clicking
|
||||
@@ -123,14 +122,6 @@ function scheduleDocumentHorizontalScrollReset() {
|
||||
setTimeout(run, 0);
|
||||
}
|
||||
|
||||
function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next: ReadonlyMap<string, string>): boolean {
|
||||
if (previous.size !== next.size) return false;
|
||||
for (const [key, value] of previous) {
|
||||
if (next.get(key) !== value) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) {
|
||||
return (
|
||||
<main className="board board-workflows-skeleton" id="board" aria-busy={!empty} aria-label={empty ? "No workflow lanes available" : "Loading workflow lanes"} data-testid={empty ? "board-workflows-empty" : "board-workflows-skeleton"}>
|
||||
@@ -148,7 +139,6 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) {
|
||||
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const archivedLoadedRef = useRef(false);
|
||||
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
|
||||
const boardRef = useRef<HTMLElement | null>(null);
|
||||
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
|
||||
if (typeof document === "undefined") return null;
|
||||
@@ -242,28 +232,6 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
return stableGrouped;
|
||||
}, [tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetchWorkflowSteps(projectId)
|
||||
.then((steps) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const nextLookup = new Map(steps.map((step) => [step.id, step.name] as const));
|
||||
setWorkflowStepNameLookup((previous) => (
|
||||
areWorkflowNameLookupsEqual(previous, nextLookup) ? previous : nextLookup
|
||||
));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setWorkflowStepNameLookup((previous) => (previous.size === 0 ? previous : EMPTY_WORKFLOW_STEP_NAME_LOOKUP));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// FN-4574 + FN-001 diagnosis: on iOS Safari, the mobile board can occasionally
|
||||
// snap against stale layout/visualViewport metrics before flex columns resolve,
|
||||
// both on initial mount and on pageshow/bfcache restore after backgrounding.
|
||||
@@ -602,7 +570,6 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
taskCardFieldDefs={taskCardFieldDefs}
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
@@ -649,7 +616,6 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
taskCardFieldDefs={taskCardFieldDefs}
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
@@ -695,7 +661,6 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
taskCardFieldDefs={taskCardFieldDefs}
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
|
||||
@@ -139,8 +139,6 @@ interface ColumnProps {
|
||||
onOpenMission?: (missionId: string) => void;
|
||||
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
|
||||
lastFetchTimeMs?: number;
|
||||
/** Lookup of workflow step IDs to display names, fetched once at board level. */
|
||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||
/** Per-task card-placed custom field definitions (U13/KTD-14). */
|
||||
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
|
||||
/** Precomputed blocker fanout keyed by blocker task ID. */
|
||||
@@ -173,7 +171,7 @@ interface ColumnProps {
|
||||
getDraggingTaskId?: () => string | null;
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
|
||||
const { t } = useTranslation("app");
|
||||
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
|
||||
// scopes `t` to the useTranslation binding, so the shared translateRejection
|
||||
@@ -728,7 +726,6 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
taskCardFieldDefs={taskCardFieldDefs}
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
@@ -762,7 +759,6 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
onPromote={isHoldColumn && onPromote ? handlePromote : undefined}
|
||||
isPromoting={isHoldColumn && onPromote ? promotingIds.has(task.id) : undefined}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
|
||||
fanout={blockerFanoutMap?.get(task.id)}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
|
||||
@@ -66,7 +66,6 @@ export interface LaneProps {
|
||||
taskStuckTimeoutMs?: number;
|
||||
onOpenMission?: (missionId: string) => void;
|
||||
lastFetchTimeMs?: number;
|
||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||
/** Per-task card-placed custom field definitions (U13/KTD-14). */
|
||||
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
|
||||
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
|
||||
@@ -202,7 +201,6 @@ function LaneComponent(props: LaneProps) {
|
||||
taskStuckTimeoutMs={props.taskStuckTimeoutMs}
|
||||
onOpenMission={props.onOpenMission}
|
||||
lastFetchTimeMs={props.lastFetchTimeMs}
|
||||
workflowStepNameLookup={props.workflowStepNameLookup}
|
||||
taskCardFieldDefs={props.taskCardFieldDefs}
|
||||
blockerFanoutMap={props.blockerFanoutMap}
|
||||
prAuthAvailable={props.prAuthAvailable}
|
||||
|
||||
@@ -1045,14 +1045,24 @@ The execution-time badge is part of the footer's bottom-right chip cluster, so i
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowSteps 2026-06-25-00:00:
|
||||
`failed` is a blocking gate failure (red/error); `advisory_failure` is a non-blocking REVISE that
|
||||
returned feedback but does not block merge (amber/warning). `running` is a graph node actively
|
||||
executing. These map 1:1 to the unified progress status so the dot color encodes blocking-ness.
|
||||
*/
|
||||
.card-step-dot--failed {
|
||||
background: var(--color-error-dark);
|
||||
}
|
||||
|
||||
.card-step-dot--workflow-failed {
|
||||
.card-step-dot--advisory_failure {
|
||||
background: var(--ws-warning);
|
||||
}
|
||||
|
||||
.card-step-dot--running {
|
||||
background: var(--todo);
|
||||
}
|
||||
|
||||
.card-step-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -401,8 +401,6 @@ interface TaskCardProps {
|
||||
isPromoting?: boolean;
|
||||
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
|
||||
lastFetchTimeMs?: number;
|
||||
/** Lookup of workflow step IDs to display names, fetched once at board level. */
|
||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||
/** Disable card drag semantics when embedding in custom draggable containers (e.g. dependency graph). */
|
||||
disableDrag?: boolean;
|
||||
/** Downstream fan-out entry for this task, computed at board-level. */
|
||||
@@ -597,7 +595,6 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.onMoveTask === next.onMoveTask &&
|
||||
previous.onPromote === next.onPromote &&
|
||||
previous.isPromoting === next.isPromoting &&
|
||||
previous.workflowStepNameLookup === next.workflowStepNameLookup &&
|
||||
previous.disableDrag === next.disableDrag &&
|
||||
previous.fanout?.totalCount === next.fanout?.totalCount &&
|
||||
previous.fanout?.activeTodoCount === next.fanout?.activeTodoCount &&
|
||||
@@ -707,7 +704,6 @@ function TaskCardComponent({
|
||||
onPromote,
|
||||
isPromoting = false,
|
||||
lastFetchTimeMs,
|
||||
workflowStepNameLookup,
|
||||
disableDrag,
|
||||
fanout,
|
||||
prAuthAvailable,
|
||||
@@ -1072,8 +1068,8 @@ function TaskCardComponent({
|
||||
return providers;
|
||||
}, [task.modelProvider, task.validatorModelProvider, task.planningModelProvider]);
|
||||
const unifiedProgress = useMemo(
|
||||
() => getUnifiedTaskProgress(task, workflowStepNameLookup),
|
||||
[task.steps, task.enabledWorkflowSteps, task.workflowStepResults, workflowStepNameLookup],
|
||||
() => getUnifiedTaskProgress(task),
|
||||
[task.steps, task.enabledWorkflowSteps, task.workflowStepResults],
|
||||
);
|
||||
const showProgressSection =
|
||||
unifiedProgress.total > 0 && (task.status === "executing" || task.column === "in-progress");
|
||||
@@ -2283,12 +2279,18 @@ function TaskCardComponent({
|
||||
{showSteps && (
|
||||
<div className="card-steps-list">
|
||||
{unifiedProgress.items.map((step) => {
|
||||
const isWorkflowFailed = step.source === "workflow" && step.status === "failed";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowSteps 2026-06-25-00:00:
|
||||
The dot color is keyed by the unified status, which now distinguishes the two
|
||||
workflow-failure modes: `advisory_failure` (non-blocking REVISE → amber/warning) vs
|
||||
`failed` (blocking gate failure → red/error). `running` shows the in-progress color.
|
||||
No `card-step-dot--workflow-failed` override is needed — the status class carries the
|
||||
distinction directly.
|
||||
*/
|
||||
return (
|
||||
<div key={step.id} className="card-step-item">
|
||||
<span
|
||||
className={`card-step-dot card-step-dot--${step.status}${isWorkflowFailed ? " card-step-dot--workflow-failed" : ""}`}
|
||||
className={`card-step-dot card-step-dot--${step.status}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className={`card-step-name${step.status === "done" ? " completed" : ""}`}>
|
||||
|
||||
@@ -28,8 +28,6 @@ interface WorktreeGroupProps {
|
||||
onOpenMission?: (missionId: string) => void;
|
||||
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
|
||||
lastFetchTimeMs?: number;
|
||||
/** Lookup of workflow step IDs to display names, fetched once at board level. */
|
||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||
/** Per-task card-placed custom field definitions (U13/KTD-14). */
|
||||
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
|
||||
/** Precomputed blocker fanout keyed by blocker task ID. */
|
||||
@@ -55,7 +53,6 @@ function WorktreeGroupComponent({
|
||||
taskStuckTimeoutMs,
|
||||
onOpenMission,
|
||||
lastFetchTimeMs,
|
||||
workflowStepNameLookup,
|
||||
taskCardFieldDefs,
|
||||
blockerFanoutMap,
|
||||
prAuthAvailable,
|
||||
@@ -92,7 +89,6 @@ function WorktreeGroupComponent({
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
|
||||
fanout={blockerFanoutMap?.get(task.id)}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
@@ -115,7 +111,6 @@ function WorktreeGroupComponent({
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
|
||||
fanout={blockerFanoutMap?.get(task.id)}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
|
||||
@@ -48,10 +48,10 @@ const columnRenderCounts: Record<string, number> = {};
|
||||
|
||||
// Mock child components so we only test Board's own rendering
|
||||
vi.mock("../Column", () => ({
|
||||
Column: React.memo(({ column, tasks, collapsed, onToggleCollapse, onQuickCreate, onNewTask, onToggleAutoMerge, onArchiveAllDone, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, workflowStepNameLookup }: { column: string; tasks: Task[]; collapsed?: boolean; onToggleCollapse?: () => void; onQuickCreate?: unknown; onNewTask?: unknown; onToggleAutoMerge?: () => void; onArchiveAllDone?: unknown; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean; workflowStepNameLookup?: ReadonlyMap<string, string> }) => {
|
||||
Column: React.memo(({ column, tasks, collapsed, onToggleCollapse, onQuickCreate, onNewTask, onToggleAutoMerge, onArchiveAllDone, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive }: { column: string; tasks: Task[]; collapsed?: boolean; onToggleCollapse?: () => void; onQuickCreate?: unknown; onNewTask?: unknown; onToggleAutoMerge?: () => void; onArchiveAllDone?: unknown; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean }) => {
|
||||
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
|
||||
return (
|
||||
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-collapsed={collapsed ? "true" : "false"} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-has-auto-merge-toggle={onToggleAutoMerge ? "yes" : "no"} data-has-archive-all={onArchiveAllDone ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)}>
|
||||
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-collapsed={collapsed ? "true" : "false"} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-has-auto-merge-toggle={onToggleAutoMerge ? "yes" : "no"} data-has-archive-all={onArchiveAllDone ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"}>
|
||||
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
|
||||
</div>
|
||||
);
|
||||
@@ -230,20 +230,6 @@ describe("Board", () => {
|
||||
expect(triageTasks[0]?.id).toBe("FN-404");
|
||||
});
|
||||
|
||||
it("forwards board-level workflow name lookup to columns", async () => {
|
||||
fetchWorkflowStepsMock.mockResolvedValue([
|
||||
{ id: "WS-003", name: "Accessibility Audit", enabled: true },
|
||||
]);
|
||||
renderBoard();
|
||||
|
||||
await waitFor(() => {
|
||||
for (const col of COLUMNS) {
|
||||
const columnEl = screen.getByTestId(`column-${col}`);
|
||||
expect(columnEl.getAttribute("data-workflow-lookup-size")).toBe("1");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("renders all 6 columns as direct children of .board (CSS selector target)", () => {
|
||||
renderBoard();
|
||||
const board = screen.getByRole("main");
|
||||
|
||||
@@ -9,10 +9,10 @@ import type { Task, Column as ColumnType } from "@fusion/core";
|
||||
const taskCardRenderSpy = vi.fn();
|
||||
|
||||
vi.mock("../TaskCard", () => ({
|
||||
TaskCard: React.memo(({ task, workflowStepNameLookup, onPromote, isPromoting }: { task: Task; workflowStepNameLookup?: ReadonlyMap<string, string>; onPromote?: (taskId: string) => Promise<void>; isPromoting?: boolean }) => {
|
||||
TaskCard: React.memo(({ task, onPromote, isPromoting }: { task: Task; onPromote?: (taskId: string) => Promise<void>; isPromoting?: boolean }) => {
|
||||
taskCardRenderSpy(task.id);
|
||||
return (
|
||||
<div data-testid={`task-${task.id}`} data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)}>
|
||||
<div data-testid={`task-${task.id}`}>
|
||||
{onPromote && (
|
||||
<button type="button" data-testid={`card-promote-${task.id}`} disabled={isPromoting} onClick={() => void onPromote(task.id)}>
|
||||
{isPromoting ? "Promoting…" : "Promote"}
|
||||
@@ -23,8 +23,8 @@ vi.mock("../TaskCard", () => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock("../WorktreeGroup", () => ({
|
||||
WorktreeGroup: ({ workflowStepNameLookup }: { workflowStepNameLookup?: ReadonlyMap<string, string> }) => (
|
||||
<div data-testid="worktree-group" data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)} />
|
||||
WorktreeGroup: () => (
|
||||
<div data-testid="worktree-group" />
|
||||
),
|
||||
}));
|
||||
vi.mock("../QuickEntryBox", () => ({
|
||||
@@ -255,31 +255,6 @@ describe("Column memoization", () => {
|
||||
expect(taskCardRenderSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forwards workflowStepNameLookup to task cards", () => {
|
||||
render(
|
||||
<Column
|
||||
{...defaultProps}
|
||||
column="todo"
|
||||
tasks={[{ ...makeTask("FN-001"), column: "todo" as ColumnType }]}
|
||||
workflowStepNameLookup={new Map([["WS-003", "Accessibility Audit"]])}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("task-FN-001").getAttribute("data-workflow-lookup-size")).toBe("1");
|
||||
});
|
||||
|
||||
it("forwards workflowStepNameLookup to in-progress worktree groups", () => {
|
||||
render(
|
||||
<Column
|
||||
{...defaultProps}
|
||||
column="in-progress"
|
||||
tasks={[{ ...makeTask("FN-001"), column: "in-progress" as ColumnType, worktree: "wt-1" }]}
|
||||
workflowStepNameLookup={new Map([["WS-003", "Accessibility Audit"]])}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("worktree-group").getAttribute("data-workflow-lookup-size")).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column pagination", () => {
|
||||
|
||||
@@ -387,7 +387,6 @@ describe("RightDock", () => {
|
||||
onCreateTaskFromInsight: vi.fn(),
|
||||
onNavigateToMission: vi.fn(),
|
||||
onTaskCreated: vi.fn(),
|
||||
workflowStepNameLookup: new Map<string, string>(),
|
||||
prAuthAvailable: false,
|
||||
autoMerge: false,
|
||||
visibilityOptions: {},
|
||||
@@ -445,7 +444,6 @@ describe("RightDock", () => {
|
||||
onCreateTaskFromInsight: vi.fn(),
|
||||
onNavigateToMission: vi.fn(),
|
||||
onTaskCreated: vi.fn(),
|
||||
workflowStepNameLookup: new Map<string, string>(),
|
||||
prAuthAvailable: false,
|
||||
autoMerge: false,
|
||||
visibilityOptions: {},
|
||||
|
||||
@@ -1909,7 +1909,10 @@ describe("TaskCard", () => {
|
||||
expect(screen.queryByText("1 steps")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders workflow checks after normal steps with mapped statuses and phase badges", () => {
|
||||
// FNXC:WorkflowSteps 2026-06-25 — graph-written results drive the card progress; names come from
|
||||
// result.workflowStepName (with raw-id fallback), and advisory_failure (amber) is visually distinct
|
||||
// from failed (red). No board-level name lookup is involved.
|
||||
it("renders workflow checks after normal steps with graph-written statuses and phase badges", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
@@ -1917,7 +1920,7 @@ describe("TaskCard", () => {
|
||||
{ name: "Step 0", status: "done" },
|
||||
{ name: "Step 1", status: "failed" as any },
|
||||
],
|
||||
enabledWorkflowSteps: ["WS-001", "WS-002", "WS-003"],
|
||||
enabledWorkflowSteps: ["WS-001", "WS-002", "WS-003", "WS-004"],
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "WS-001",
|
||||
@@ -1927,53 +1930,90 @@ describe("TaskCard", () => {
|
||||
{
|
||||
workflowStepId: "WS-002",
|
||||
workflowStepName: "Frontend UX Design",
|
||||
status: "failed",
|
||||
status: "advisory_failure",
|
||||
phase: "post-merge",
|
||||
},
|
||||
{
|
||||
workflowStepId: "WS-004",
|
||||
workflowStepName: "Code Review Gate",
|
||||
status: "failed",
|
||||
},
|
||||
],
|
||||
})}
|
||||
workflowStepNameLookup={new Map([["WS-003", "Accessibility Audit"]])}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
|
||||
// WS-003 has no result → name falls back to the raw id; all others resolve from result.workflowStepName.
|
||||
expect(stepNames).toEqual([
|
||||
"Step 0",
|
||||
"Step 1",
|
||||
"Browser Verification",
|
||||
"Frontend UX Design",
|
||||
"Accessibility Audit",
|
||||
"WS-003",
|
||||
"Code Review Gate",
|
||||
]);
|
||||
|
||||
const dots = container.querySelectorAll(".card-step-dot");
|
||||
// Impl step failure → blocking red.
|
||||
expect(dots[1]?.className).toContain("card-step-dot--failed");
|
||||
expect(dots[1]?.className).not.toContain("card-step-dot--workflow-failed");
|
||||
expect(dots[1]?.className).not.toContain("card-step-dot--advisory_failure");
|
||||
|
||||
// Passed workflow step → done.
|
||||
expect(dots[2]?.className).toContain("card-step-dot--done");
|
||||
expect(dots[2]?.className).not.toContain("card-step-dot--workflow-failed");
|
||||
|
||||
expect(dots[3]?.className).toContain("card-step-dot--failed");
|
||||
expect(dots[3]?.className).toContain("card-step-dot--workflow-failed");
|
||||
// advisory_failure → amber, NOT the blocking red failed class.
|
||||
expect(dots[3]?.className).toContain("card-step-dot--advisory_failure");
|
||||
expect(dots[3]?.className).not.toContain("card-step-dot--failed");
|
||||
|
||||
// Enabled-but-not-run → pending.
|
||||
expect(dots[4]?.className).toContain("card-step-dot--pending");
|
||||
expect(dots[4]?.className).not.toContain("card-step-dot--workflow-failed");
|
||||
|
||||
// Gate failure → blocking red, NOT amber advisory.
|
||||
expect(dots[5]?.className).toContain("card-step-dot--failed");
|
||||
expect(dots[5]?.className).not.toContain("card-step-dot--advisory_failure");
|
||||
|
||||
const workflowBadgeElements = container.querySelectorAll(".card-step-workflow-badge");
|
||||
const workflowBadges = Array.from(workflowBadgeElements).map((el) => el.textContent);
|
||||
expect(workflowBadges).toEqual(["workflow", "workflow", "workflow"]);
|
||||
expect(workflowBadges).toEqual(["workflow", "workflow", "workflow", "workflow"]);
|
||||
|
||||
expect(workflowBadgeElements[0]?.className).toContain("card-step-workflow-badge--pre-merge");
|
||||
expect(workflowBadgeElements[1]?.className).toContain("card-step-workflow-badge--post-merge");
|
||||
expect(workflowBadgeElements[2]?.className).toContain("card-step-workflow-badge--pre-merge");
|
||||
expect(workflowBadgeElements[3]?.className).toContain("card-step-workflow-badge--pre-merge");
|
||||
|
||||
workflowBadgeElements.forEach((badge) => {
|
||||
expect(badge.getAttribute("title")).toBe("Workflow check");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to workflow result name, then raw ID when lookup names are unavailable", () => {
|
||||
it("renders the running state for a started-but-not-completed workflow step", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Browser Verification",
|
||||
status: "pending",
|
||||
startedAt: "2026-06-25T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dots = container.querySelectorAll(".card-step-dot");
|
||||
expect(dots[0]?.className).toContain("card-step-dot--running");
|
||||
expect(dots[0]?.className).not.toContain("card-step-dot--pending");
|
||||
});
|
||||
|
||||
it("falls back to the raw workflow step ID when the result name is blank", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
@@ -1981,19 +2021,19 @@ describe("TaskCard", () => {
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "WS-002",
|
||||
workflowStepName: "Fallback from result",
|
||||
workflowStepName: " ",
|
||||
status: "passed",
|
||||
},
|
||||
],
|
||||
})}
|
||||
workflowStepNameLookup={new Map([["WS-002", " "]])}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
|
||||
expect(stepNames).toEqual(["Fallback from result", "WS-003"]);
|
||||
// Blank result name → fall back to id; WS-003 (no result) → id.
|
||||
expect(stepNames).toEqual(["WS-002", "WS-003"]);
|
||||
});
|
||||
|
||||
it("shows drop indicator on file dragover and removes on dragleave", () => {
|
||||
|
||||
@@ -68,7 +68,6 @@ export function MainContent({
|
||||
subscribePluginEvents,
|
||||
openDetailTask,
|
||||
openFileInBrowser,
|
||||
workflowStepNameLookup,
|
||||
prAuthAvailable,
|
||||
autoMerge,
|
||||
settingsLoaded,
|
||||
@@ -279,7 +278,6 @@ export function MainContent({
|
||||
projectId={currentProject?.id}
|
||||
onOpenDetail={(value: Task | TaskDetail) => openDetailTask(value)}
|
||||
addToast={addToast}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
disableDrag={true}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMergeEnabled={autoMerge}
|
||||
|
||||
@@ -108,7 +108,6 @@ export interface MainContentProps {
|
||||
options?: { origin?: DetailTaskOrigin },
|
||||
) => void;
|
||||
openFileInBrowser: (path: string, opts?: { workspace?: string; line?: number; col?: number }) => void;
|
||||
workflowStepNameLookup: Map<string, string>;
|
||||
prAuthAvailable: boolean;
|
||||
autoMerge: boolean;
|
||||
settingsLoaded: boolean;
|
||||
|
||||
@@ -33,7 +33,6 @@ export interface RightDockControllerInput {
|
||||
onCreateTaskFromInsight: (payload: { insightId: string; title: string; description: string }) => Promise<void> | void;
|
||||
onNavigateToMission: (missionId: string) => void;
|
||||
onTaskCreated: (task: Task) => void;
|
||||
workflowStepNameLookup: Map<string, string>;
|
||||
prAuthAvailable: boolean;
|
||||
autoMerge: boolean;
|
||||
visibilityOptions: OverflowViewVisibilityOptions;
|
||||
@@ -104,7 +103,6 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
|
||||
projectId={input.projectId}
|
||||
onOpenDetail={(value: Task | TaskDetail) => input.openDetailTask(value)}
|
||||
addToast={input.addToast}
|
||||
workflowStepNameLookup={input.workflowStepNameLookup}
|
||||
disableDrag={true}
|
||||
prAuthAvailable={input.prAuthAvailable}
|
||||
autoMergeEnabled={input.autoMerge}
|
||||
|
||||
173
packages/dashboard/app/utils/__tests__/taskProgress.test.ts
Normal file
173
packages/dashboard/app/utils/__tests__/taskProgress.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { getUnifiedTaskProgress } from "../taskProgress";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowSteps 2026-06-25 — graph-native progress model (plan U3).
|
||||
These tests pin the render-state contract that the progress bar / Workflow tab rely on:
|
||||
- names resolve from result.workflowStepName (no DB-row name lookup), with a raw-id fallback;
|
||||
- a "pending" result with a startedAt and no completedAt is the `running` state, vs bare `pending`;
|
||||
- advisory_failure (non-blocking) is distinct from failed (blocking) and counts as completed;
|
||||
- disabled optional steps (absent from enabledWorkflowSteps) never appear in the counter/bar.
|
||||
*/
|
||||
|
||||
function makeTask(overrides: Partial<Pick<Task, "steps" | "enabledWorkflowSteps" | "workflowStepResults">>) {
|
||||
return {
|
||||
steps: [],
|
||||
enabledWorkflowSteps: [],
|
||||
workflowStepResults: [],
|
||||
...overrides,
|
||||
} as Pick<Task, "steps" | "enabledWorkflowSteps" | "workflowStepResults">;
|
||||
}
|
||||
|
||||
describe("getUnifiedTaskProgress", () => {
|
||||
it("resolves workflow step names from result.workflowStepName without a lookup", () => {
|
||||
const progress = getUnifiedTaskProgress(
|
||||
makeTask({
|
||||
enabledWorkflowSteps: ["browser-verification"],
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "browser-verification",
|
||||
workflowStepName: "Browser Verification",
|
||||
status: "passed",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const item = progress.items.find((i) => i.id === "workflow-browser-verification");
|
||||
expect(item?.name).toBe("Browser Verification");
|
||||
expect(item?.status).toBe("done");
|
||||
});
|
||||
|
||||
it("falls back to the workflow step id when no result name is available", () => {
|
||||
const progress = getUnifiedTaskProgress(
|
||||
makeTask({
|
||||
enabledWorkflowSteps: ["code-review"],
|
||||
workflowStepResults: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const item = progress.items.find((i) => i.id === "workflow-code-review");
|
||||
expect(item?.name).toBe("code-review");
|
||||
// Enabled but never run → pending.
|
||||
expect(item?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("distinguishes running (started, not completed) from pending (not started)", () => {
|
||||
const progress = getUnifiedTaskProgress(
|
||||
makeTask({
|
||||
enabledWorkflowSteps: ["running-step", "not-started-step"],
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "running-step",
|
||||
workflowStepName: "Running Step",
|
||||
status: "pending",
|
||||
startedAt: "2026-06-25T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(progress.items.find((i) => i.id === "workflow-running-step")?.status).toBe("running");
|
||||
expect(progress.items.find((i) => i.id === "workflow-not-started-step")?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("treats a completed pending entry (startedAt + completedAt) as pending, not running", () => {
|
||||
// Defensive: a terminal entry should carry a non-pending status, but if a pending entry has
|
||||
// both timestamps it is not actively running.
|
||||
const progress = getUnifiedTaskProgress(
|
||||
makeTask({
|
||||
enabledWorkflowSteps: ["edge"],
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "edge",
|
||||
workflowStepName: "Edge",
|
||||
status: "pending",
|
||||
startedAt: "2026-06-25T00:00:00.000Z",
|
||||
completedAt: "2026-06-25T00:01:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(progress.items.find((i) => i.id === "workflow-edge")?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("maps advisory_failure (non-blocking) distinctly from failed (blocking)", () => {
|
||||
const progress = getUnifiedTaskProgress(
|
||||
makeTask({
|
||||
enabledWorkflowSteps: ["advisory", "gate"],
|
||||
workflowStepResults: [
|
||||
{ workflowStepId: "advisory", workflowStepName: "Advisory", status: "advisory_failure" },
|
||||
{ workflowStepId: "gate", workflowStepName: "Gate", status: "failed" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(progress.items.find((i) => i.id === "workflow-advisory")?.status).toBe("advisory_failure");
|
||||
expect(progress.items.find((i) => i.id === "workflow-gate")?.status).toBe("failed");
|
||||
// advisory_failure counts as completed (non-blocking); failed does not.
|
||||
expect(progress.completed).toBe(1);
|
||||
});
|
||||
|
||||
it("excludes a disabled step (absent from enabledWorkflowSteps) from the count", () => {
|
||||
const progress = getUnifiedTaskProgress(
|
||||
makeTask({
|
||||
// "disabled-step" is toggled off → not in enabledWorkflowSteps, even though a stale result exists.
|
||||
enabledWorkflowSteps: ["code-review"],
|
||||
workflowStepResults: [
|
||||
{ workflowStepId: "code-review", workflowStepName: "Code Review", status: "passed" },
|
||||
{ workflowStepId: "disabled-step", workflowStepName: "Disabled", status: "passed" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(progress.total).toBe(1);
|
||||
expect(progress.items.some((i) => i.id === "workflow-disabled-step")).toBe(false);
|
||||
});
|
||||
|
||||
it("produces 8 items with the correct completed count for 6 impl steps + 2 workflow steps", () => {
|
||||
const progress = getUnifiedTaskProgress(
|
||||
makeTask({
|
||||
steps: [
|
||||
{ name: "Step 1", status: "done" },
|
||||
{ name: "Step 2", status: "done" },
|
||||
{ name: "Step 3", status: "done" },
|
||||
{ name: "Step 4", status: "done" },
|
||||
{ name: "Step 5", status: "done" },
|
||||
{ name: "Step 6", status: "done" },
|
||||
],
|
||||
enabledWorkflowSteps: ["browser-verification", "code-review"],
|
||||
workflowStepResults: [
|
||||
{ workflowStepId: "browser-verification", workflowStepName: "Browser Verification", status: "passed" },
|
||||
{ workflowStepId: "code-review", workflowStepName: "Code Review", status: "advisory_failure" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(progress.total).toBe(8);
|
||||
expect(progress.items).toHaveLength(8);
|
||||
// 6 done impl steps + 1 passed (done) + 1 advisory_failure (non-blocking → completed) = 8.
|
||||
expect(progress.completed).toBe(8);
|
||||
expect(progress.items.filter((i) => i.source === "step")).toHaveLength(6);
|
||||
expect(progress.items.filter((i) => i.source === "workflow")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("maps impl step statuses straight through and skipped as completed", () => {
|
||||
const progress = getUnifiedTaskProgress(
|
||||
makeTask({
|
||||
steps: [
|
||||
{ name: "Done", status: "done" },
|
||||
{ name: "Skipped", status: "skipped" },
|
||||
{ name: "In progress", status: "in-progress" },
|
||||
{ name: "Pending", status: "pending" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(progress.total).toBe(4);
|
||||
// done + skipped count as completed; in-progress + pending do not.
|
||||
expect(progress.completed).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,24 @@
|
||||
import type { Task, WorkflowStepResult, WorkflowStepPhase, StepStatus } from "@fusion/core";
|
||||
|
||||
export type UnifiedTaskProgressStatus = StepStatus | "failed";
|
||||
/*
|
||||
FNXC:WorkflowSteps 2026-06-25-00:00:
|
||||
Graph-native workflow steps (plan U3). Workflow step status now comes entirely from the graph-written
|
||||
`task.workflowStepResults` entries (keyed by node id === enabledWorkflowSteps[i]); the legacy
|
||||
`/api/workflow-steps` DB-row name lookup was dropped, so step names resolve from `result.workflowStepName`
|
||||
with a fallback to the raw id.
|
||||
|
||||
Render states (design-lens): the progress model distinguishes
|
||||
- `pending` (enabled, never started — no `startedAt`)
|
||||
- `running` (graph node active — `pending` status with a `startedAt` and no `completedAt`)
|
||||
- `done` (passed)
|
||||
- `advisory_failure` (non-blocking REVISE — amber, counts as completed; does not block merge)
|
||||
- `failed` (blocking gate failure — red)
|
||||
- `skipped`
|
||||
Disabled optional steps are simply absent from `enabledWorkflowSteps`, so they never appear in the
|
||||
counter/bar.
|
||||
*/
|
||||
|
||||
export type UnifiedTaskProgressStatus = StepStatus | "failed" | "advisory_failure" | "running";
|
||||
|
||||
export interface UnifiedTaskProgressItem {
|
||||
id: string;
|
||||
@@ -16,46 +34,41 @@ export interface UnifiedTaskProgress {
|
||||
items: UnifiedTaskProgressItem[];
|
||||
}
|
||||
|
||||
function mapWorkflowStatus(status: WorkflowStepResult["status"]): UnifiedTaskProgressStatus {
|
||||
switch (status) {
|
||||
function mapWorkflowStatus(result: WorkflowStepResult): UnifiedTaskProgressStatus {
|
||||
switch (result.status) {
|
||||
case "passed":
|
||||
return "done";
|
||||
case "failed":
|
||||
case "advisory_failure":
|
||||
return "failed";
|
||||
case "advisory_failure":
|
||||
return "advisory_failure";
|
||||
case "skipped":
|
||||
return "skipped";
|
||||
case "pending":
|
||||
default:
|
||||
return "pending";
|
||||
// The graph upserts a `pending` entry when a step starts running. A started-but-not-completed
|
||||
// entry is the in-progress/`running` display state; a bare `pending` (no `startedAt`) is an
|
||||
// enabled step that has not begun yet.
|
||||
return result.startedAt && !result.completedAt ? "running" : "pending";
|
||||
}
|
||||
}
|
||||
|
||||
function isCompleted(status: UnifiedTaskProgressStatus): boolean {
|
||||
return status === "done" || status === "skipped";
|
||||
// advisory_failure is non-blocking: the step ran and returned feedback, so it counts as completed
|
||||
// (overall progress reads complete when only advisory steps returned REVISE).
|
||||
return status === "done" || status === "skipped" || status === "advisory_failure";
|
||||
}
|
||||
|
||||
function resolveWorkflowStepName(
|
||||
workflowStepId: string,
|
||||
result: WorkflowStepResult | undefined,
|
||||
workflowStepNameLookup?: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
const lookupName = workflowStepNameLookup?.get(workflowStepId)?.trim();
|
||||
if (lookupName) {
|
||||
return lookupName;
|
||||
}
|
||||
|
||||
function resolveWorkflowStepName(workflowStepId: string, result: WorkflowStepResult | undefined): string {
|
||||
const resultName = result?.workflowStepName?.trim();
|
||||
if (resultName) {
|
||||
return resultName;
|
||||
}
|
||||
|
||||
return workflowStepId;
|
||||
}
|
||||
|
||||
export function getUnifiedTaskProgress(
|
||||
task: Pick<Task, "steps" | "enabledWorkflowSteps" | "workflowStepResults">,
|
||||
workflowStepNameLookup?: ReadonlyMap<string, string>,
|
||||
): UnifiedTaskProgress {
|
||||
const stepItems: UnifiedTaskProgressItem[] = (task.steps ?? []).map((step, index) => ({
|
||||
id: `step-${index}`,
|
||||
@@ -73,8 +86,8 @@ export function getUnifiedTaskProgress(
|
||||
const result = workflowResultsById.get(workflowStepId);
|
||||
return {
|
||||
id: `workflow-${workflowStepId}`,
|
||||
name: resolveWorkflowStepName(workflowStepId, result, workflowStepNameLookup),
|
||||
status: result ? mapWorkflowStatus(result.status) : "pending",
|
||||
name: resolveWorkflowStepName(workflowStepId, result),
|
||||
status: result ? mapWorkflowStatus(result) : "pending",
|
||||
source: "workflow",
|
||||
phase: result?.phase ?? "pre-merge",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user