fix: address workflow PR feedback

This commit is contained in:
gsxdsm
2026-06-07 23:29:37 -07:00
parent e138971030
commit 56b194383d
13 changed files with 350 additions and 54 deletions

View File

@@ -452,23 +452,25 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
if (workflowMode && selectedWorkflow) {
return (
<div className="board-workflow-view">
{workflowOptions.length > 1 && (
{(workflowOptions.length > 1 || onCreateWorkflow || onOpenWorkflowEditor) && (
<div className="board-workflow-toolbar">
<label className="list-workflow-selector board-workflow-selector">
<span>Workflow</span>
<select
className="select list-workflow-select"
value={selectedWorkflow.id}
onChange={(event) => setSelectedWorkflowId(event.target.value)}
aria-label="Select workflow"
>
{workflowOptions.map((workflow) => (
<option key={workflow.id} value={workflow.id}>
{workflow.name}
</option>
))}
</select>
</label>
{workflowOptions.length > 1 && (
<label className="list-workflow-selector board-workflow-selector">
<span>Workflow</span>
<select
className="select list-workflow-select"
value={selectedWorkflow.id}
onChange={(event) => setSelectedWorkflowId(event.target.value)}
aria-label="Select workflow"
>
{workflowOptions.map((workflow) => (
<option key={workflow.id} value={workflow.id}>
{workflow.name}
</option>
))}
</select>
</label>
)}
{onCreateWorkflow && (
<button
type="button"

View File

@@ -386,11 +386,10 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
(input: TaskCreateInput) => {
if (!onQuickCreate) return Promise.resolve();
if (workflowMode) {
const explicitWorkflowId = workflowId?.startsWith("builtin:") ? undefined : workflowId;
return onQuickCreate({
...input,
column,
...(explicitWorkflowId ? { workflowId: explicitWorkflowId } : {}),
...(workflowId ? { workflowId } : {}),
});
}
return onQuickCreate(input);

View File

@@ -29,6 +29,7 @@
.board-workflow-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-md);
padding: var(--space-md) var(--space-xl);
border-bottom: 1px solid var(--border);

View File

@@ -631,11 +631,10 @@ export function ListView({
const handleListQuickCreate = useCallback((input: TaskCreateInput) => {
const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"));
if (workflowMode && selectedWorkflow && createTargetColumn) {
const explicitWorkflowId = selectedWorkflow.id.startsWith("builtin:") ? undefined : selectedWorkflow.id;
return create({
...input,
column: input.column ?? createTargetColumn,
workflowId: input.workflowId ?? explicitWorkflowId,
workflowId: input.workflowId ?? selectedWorkflow.id,
});
}
return create(input);
@@ -873,16 +872,16 @@ export function ListView({
const selectedTasks = Array.from(selectedTaskIds)
.map((id) => tasks.find((task) => task.id === id))
.filter((task): task is Task => Boolean(task));
const archivedTasks = selectedTasks.filter((task) => task.column === "archived");
const deletableTasks = selectedTasks.filter((task) => task.column !== "archived");
const archivedTasks = selectedTasks.filter((task) => isArchivedColumn(task.column));
const deletableTasks = selectedTasks.filter((task) => !isArchivedColumn(task.column));
if (deletableTasks.length === 0) {
addToast(t("listView.bulkDeleteNoTasks", "No selected tasks can be deleted (archived tasks are excluded)"), "error");
return;
}
const doneTasks = deletableTasks.filter((task) => task.column === "done");
const otherTasks = deletableTasks.filter((task) => task.column !== "done");
const doneTasks = deletableTasks.filter((task) => isCompleteColumn(task.column));
const otherTasks = deletableTasks.filter((task) => !isCompleteColumn(task.column));
let shouldDeleteAll = false;
let shouldArchiveDoneInstead = false;
@@ -1070,7 +1069,7 @@ export function ListView({
: t("listView.bulkDeleteSummary", { count: deletedIds.length, skipped: skippedIds.length, failed: failedIds.length, defaultValue_one: "Deleted {{count}} task · {{skipped}} archived skipped · {{failed}} failed", defaultValue_other: "Deleted {{count}} tasks · {{skipped}} archived skipped · {{failed}} failed" });
addToast(summaryMessage, failedIds.length > 0 ? "error" : "success");
}, [addToast, confirm, confirmWithChoice, onArchiveTask, onDeleteTask, selectedTaskIds, tasks]);
}, [addToast, confirm, confirmWithChoice, isArchivedColumn, isCompleteColumn, onArchiveTask, onDeleteTask, selectedTaskIds, tasks]);
const handleBulkPause = useCallback(async () => {
if (selectedTaskIds.size === 0) return;
@@ -1082,7 +1081,7 @@ export function ListView({
const selectedTasks = Array.from(selectedTaskIds)
.map((id) => tasks.find((task) => task.id === id))
.filter((task): task is Task => Boolean(task));
const actionableTasks = selectedTasks.filter((task) => task.column !== "archived" && task.paused !== true);
const actionableTasks = selectedTasks.filter((task) => !isArchivedColumn(task.column) && task.paused !== true);
const skippedCount = selectedTasks.length - actionableTasks.length;
if (actionableTasks.length === 0) {
@@ -1121,7 +1120,7 @@ export function ListView({
t("listView.bulkPauseSummary", "Paused {{paused}} · {{skipped}} skipped · {{failed}} failed", { paused: pausedIds.length, skipped: skippedCount, failed: failedIds.length }),
failedIds.length > 0 ? "error" : "success",
);
}, [addToast, onPauseTask, selectedTaskIds, tasks]);
}, [addToast, isArchivedColumn, onPauseTask, selectedTaskIds, tasks]);
const handleBulkUnpause = useCallback(async () => {
if (selectedTaskIds.size === 0) return;
@@ -1133,7 +1132,7 @@ export function ListView({
const selectedTasks = Array.from(selectedTaskIds)
.map((id) => tasks.find((task) => task.id === id))
.filter((task): task is Task => Boolean(task));
const actionableTasks = selectedTasks.filter((task) => task.column !== "archived" && task.paused === true);
const actionableTasks = selectedTasks.filter((task) => !isArchivedColumn(task.column) && task.paused === true);
const skippedCount = selectedTasks.length - actionableTasks.length;
if (actionableTasks.length === 0) {
@@ -1172,7 +1171,7 @@ export function ListView({
t("listView.bulkUnpauseSummary", "Unpaused {{unpaused}} · {{skipped}} skipped · {{failed}} failed", { unpaused: unpausedIds.length, skipped: skippedCount, failed: failedIds.length }),
failedIds.length > 0 ? "error" : "success",
);
}, [addToast, onUnpauseTask, selectedTaskIds, tasks]);
}, [addToast, isArchivedColumn, onUnpauseTask, selectedTaskIds, tasks]);
const handleBulkArchive = useCallback(async () => {
if (selectedTaskIds.size === 0) return;
@@ -1512,7 +1511,7 @@ export function ListView({
if (!taskId) return;
// Prevent dropping into archived column
if (column === "archived" || columnFlagsById.get(column)?.archived) {
if (isArchivedColumn(column)) {
addToast(t("listView.archiveViaButton", "Tasks can only be archived via the archive button"), "error");
return;
}
@@ -1520,7 +1519,10 @@ export function ListView({
try {
const task = tasks.find((candidate) => candidate.id === taskId);
const hasStepProgress = task?.steps.some((step) => step.status !== "pending") ?? false;
const shouldPrompt = (column === "todo" || column === "triage") && hasStepProgress;
const targetFlags = columnFlagsById.get(column);
const shouldPrompt = hasStepProgress && (
column === "todo" || column === "triage" || Boolean(targetFlags?.intake || targetFlags?.hold)
);
let moveOptions: { preserveProgress?: boolean } | undefined;
if (shouldPrompt) {
@@ -1552,7 +1554,7 @@ export function ListView({
addToast(getErrorMessage(err), "error");
}
},
[addToast, columnFlagsById, confirm, onMoveTask, tasks, t]
[addToast, columnFlagsById, confirm, isArchivedColumn, onMoveTask, tasks, t]
);
const getSortIcon = (field: SortField) => {
@@ -1941,7 +1943,7 @@ export function ListView({
<div className="list-empty-cell list-card-empty">{t("listView.noTasks", "No tasks")}</div>
) : (
columnTasks.map((task) => {
const isDoneColumn = task.column === "done";
const isDoneColumn = isCompleteColumn(task.column);
const visualStatus = isDoneColumn ? "done" : task.status;
const isFailed = !isDoneColumn && task.status === "failed";
const isPaused = !isDoneColumn && task.paused === true;
@@ -1975,7 +1977,7 @@ export function ListView({
toggleTaskSelection(task.id);
}}
onClick={(e) => e.stopPropagation()}
disabled={task.column === "archived"}
disabled={isArchivedColumn(task.column)}
aria-label={t("listView.selectTask", "Select {{taskId}}", { taskId: task.id })}
/>
</label>
@@ -2134,7 +2136,7 @@ export function ListView({
</tr>
) : (
columnTasks.map((task) => {
const isDoneColumn = task.column === "done";
const isDoneColumn = isCompleteColumn(task.column);
const visualStatus = isDoneColumn ? "done" : task.status;
const isFailed = !isDoneColumn && task.status === "failed";
const isPaused = !isDoneColumn && task.paused === true;
@@ -2171,7 +2173,7 @@ export function ListView({
toggleTaskSelection(task.id);
}}
onClick={(e) => e.stopPropagation()}
disabled={task.column === "archived"}
disabled={isArchivedColumn(task.column)}
aria-label={t("listView.selectTask", "Select {{taskId}}", { taskId: task.id })}
/>
</td>

View File

@@ -971,6 +971,26 @@ describe("Board", () => {
expect(screen.getByTestId("column-idea").getAttribute("data-has-quick-create")).toBe("yes");
});
it("keeps workflow create and edit actions visible when only one workflow exists", async () => {
const onCreateWorkflow = vi.fn();
const onOpenWorkflowEditor = vi.fn();
enableFlag({ "FN-1": "builtin:coding" }, [DEFAULT_WORKFLOW]);
renderBoard({
tasks: [mkTask({ id: "FN-1", column: "triage" })],
onCreateWorkflow,
onOpenWorkflowEditor,
});
await waitFor(() => expect(screen.getByTestId("column-triage")).toBeDefined());
expect(screen.queryByLabelText("Select workflow")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "New workflow" }));
fireEvent.click(screen.getByRole("button", { name: "Edit workflows" }));
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
expect(onOpenWorkflowEditor).toHaveBeenCalledTimes(1);
});
it("renders one selected workflow at a time and switches workflows from the dropdown", async () => {
const onCreateWorkflow = vi.fn();
enableFlag(

View File

@@ -20,7 +20,7 @@ vi.mock("../WorktreeGroup", () => ({
),
}));
vi.mock("../QuickEntryBox", () => ({
QuickEntryBox: ({ favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, autoExpand }: { favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; autoExpand?: boolean }) => (
QuickEntryBox: ({ favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, autoExpand, onCreate }: { favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; autoExpand?: boolean; onCreate?: (input: { description: string }) => void }) => (
<div
data-testid="quick-entry-box"
data-favorite-providers={JSON.stringify(favoriteProviders ?? [])}
@@ -28,7 +28,9 @@ vi.mock("../QuickEntryBox", () => ({
data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"}
data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"}
data-auto-expand={autoExpand === false ? "false" : "true"}
/>
>
<button type="button" onClick={() => onCreate?.({ description: "Quick task" })}>create</button>
</div>
),
}));
vi.mock("lucide-react", () => ({
@@ -409,6 +411,28 @@ describe("Column QuickEntryBox", () => {
const quickEntry = screen.getByTestId("quick-entry-box");
expect(quickEntry.getAttribute("data-auto-expand")).toBe("false");
});
it("preserves selected built-in workflow id when quick-creating in workflow mode", async () => {
const onQuickCreate = vi.fn().mockResolvedValue({});
render(
<Column
{...defaultProps}
column="triage"
workflowMode
workflowId="builtin:coding"
tasks={[]}
onQuickCreate={onQuickCreate}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "create" }));
await waitFor(() => expect(onQuickCreate).toHaveBeenCalledWith({
description: "Quick task",
column: "triage",
workflowId: "builtin:coding",
}));
});
});
describe("Column in-progress/in-review bulk actions", () => {

View File

@@ -1350,6 +1350,52 @@ describe("ListView", () => {
});
});
it("prompts to preserve progress when dropping task with completed steps to a workflow hold column", async () => {
const tasks = [createMockTask({
id: "FN-001",
column: "doing",
steps: [
{ title: "Step 1", status: "done" },
{ title: "Step 2", status: "pending" },
],
})];
const mockOnMoveTask = vi.fn(() => Promise.resolve(tasks[0]));
mockConfirm.mockResolvedValueOnce(true);
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "wf-custom",
workflows: [
{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "queue", name: "Queue", flags: { hold: true } },
{ id: "doing", name: "Doing", flags: { countsTowardWip: true } },
{ id: "shipped", name: "Shipped", flags: { complete: true } },
],
},
],
taskWorkflowIds: { "FN-001": "wf-custom" },
});
renderListView({ tasks, onMoveTask: mockOnMoveTask });
await waitFor(() => expect(document.querySelector('[data-column="queue"].list-drop-zone')).toBeTruthy());
fireEvent.drop(document.querySelector('[data-column="queue"].list-drop-zone')!, {
preventDefault: vi.fn(),
dataTransfer: {
getData: vi.fn(() => "FN-001"),
},
});
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
title: "Preserve Progress?",
}));
expect(mockOnMoveTask).toHaveBeenCalledWith("FN-001", "queue", { preserveProgress: true });
});
});
it("does not set draggable for paused tasks", () => {
const tasks = [createMockTask({ id: "FN-001", paused: true })];
@@ -2334,6 +2380,42 @@ describe("ListView Quick Entry", () => {
});
});
it("preserves selected built-in workflow id when quick-creating in workflow mode", async () => {
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "builtin:default",
workflows: [
{
id: "builtin:default",
name: "Default",
columns: [{ id: "triage", name: "Triage", flags: { intake: true } }],
},
{
id: "builtin:coding",
name: "Coding",
columns: [{ id: "triage", name: "Triage", flags: { intake: true } }],
},
],
taskWorkflowIds: {},
});
renderListView({ onQuickCreate: mockOnQuickCreate });
const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
fireEvent.change(selector, { target: { value: "builtin:coding" } });
const input = screen.getByTestId("quick-entry-input");
fireEvent.change(input, { target: { value: "Built-in workflow task" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => {
expect(mockOnQuickCreate).toHaveBeenCalledWith(expect.objectContaining({
description: "Built-in workflow task",
column: "triage",
workflowId: "builtin:coding",
}));
});
});
it("shows error toast when onQuickCreate fails and keeps input content", async () => {
const mockOnQuickCreate = vi.fn().mockRejectedValue(new Error("Create failed"));
renderListView({ onQuickCreate: mockOnQuickCreate });
@@ -2728,6 +2810,33 @@ describe("ListView - Bulk Selection", () => {
expect(checkbox).toBeDisabled();
});
it("disables checkbox for workflow archived columns", async () => {
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "wf-custom",
workflows: [
{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "active", name: "Active", flags: { countsTowardWip: true } },
{ id: "parked", name: "Parked", flags: { archived: true } },
],
},
],
taskWorkflowIds: { "FN-001": "wf-custom" },
});
const tasks = [
createMockTask({ id: "FN-001", column: "parked" }),
];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} />);
await waitFor(() => expect(screen.queryAllByText("Parked").length).toBeGreaterThan(0));
enterBulkEditMode();
expect(screen.getByLabelText("Select FN-001")).toBeDisabled();
});
it("shows selection count when tasks are selected", () => {
const tasks = [
createMockTask({ id: "FN-001" }),
@@ -3020,6 +3129,78 @@ describe("ListView - Bulk Selection", () => {
expect(mockAddToast).toHaveBeenCalledWith("Archived 1 · 1 skipped · 0 failed", "success");
});
it("skips workflow archived-column tasks when pausing in bulk", async () => {
const user = userEvent.setup();
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "wf-custom",
workflows: [
{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "active", name: "Active", flags: { countsTowardWip: true } },
{ id: "parked", name: "Parked", flags: { archived: true } },
],
},
],
taskWorkflowIds: { "FN-001": "wf-custom", "FN-002": "wf-custom" },
});
const tasks = [
createMockTask({ id: "FN-001", column: "active", paused: false }),
createMockTask({ id: "FN-002", column: "parked", paused: false }),
];
const onPauseTask = vi.fn(async () => createMockTask());
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002"]));
renderListView({ tasks, onPauseTask });
enterBulkEditMode();
await waitFor(() => expect(screen.queryAllByText("Parked").length).toBeGreaterThan(0));
await user.click(screen.getByRole("button", { name: /^pause selected$/i }));
await waitFor(() => {
expect(onPauseTask).toHaveBeenCalledTimes(1);
expect(onPauseTask).toHaveBeenCalledWith("FN-001");
});
expect(mockAddToast).toHaveBeenCalledWith("Paused 1 · 1 skipped · 0 failed", "success");
});
it("skips workflow archived-column tasks when unpausing in bulk", async () => {
const user = userEvent.setup();
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "wf-custom",
workflows: [
{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "active", name: "Active", flags: { countsTowardWip: true } },
{ id: "parked", name: "Parked", flags: { archived: true } },
],
},
],
taskWorkflowIds: { "FN-001": "wf-custom", "FN-002": "wf-custom" },
});
const tasks = [
createMockTask({ id: "FN-001", column: "active", paused: true }),
createMockTask({ id: "FN-002", column: "parked", paused: true }),
];
const onUnpauseTask = vi.fn(async () => createMockTask());
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002"]));
renderListView({ tasks, onUnpauseTask });
enterBulkEditMode();
await waitFor(() => expect(screen.queryAllByText("Parked").length).toBeGreaterThan(0));
await user.click(screen.getByRole("button", { name: /^unpause selected$/i }));
await waitFor(() => {
expect(onUnpauseTask).toHaveBeenCalledTimes(1);
expect(onUnpauseTask).toHaveBeenCalledWith("FN-001");
});
expect(mockAddToast).toHaveBeenCalledWith("Unpaused 1 · 1 skipped · 0 failed", "success");
});
it("shows error summary when pause has failures", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001", paused: false })];
@@ -3108,6 +3289,48 @@ describe("ListView - Bulk Selection", () => {
expect(screen.getByText("1 selected")).toBeInTheDocument();
});
it("uses workflow complete and archived flags when bulk delete archives done tasks", async () => {
const user = userEvent.setup();
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "wf-custom",
workflows: [
{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "doing", name: "Doing", flags: { countsTowardWip: true } },
{ id: "shipped", name: "Shipped", flags: { complete: true } },
{ id: "parked", name: "Parked", flags: { archived: true } },
],
},
],
taskWorkflowIds: { "FN-001": "wf-custom", "FN-002": "wf-custom", "FN-003": "wf-custom" },
});
const tasks = [
createMockTask({ id: "FN-001", column: "shipped" }),
createMockTask({ id: "FN-002", column: "doing" }),
createMockTask({ id: "FN-003", column: "parked" }),
];
const onArchiveTask = vi.fn(async () => createMockTask());
const onDeleteTask = vi.fn(async () => createMockTask());
mockConfirmWithChoice.mockResolvedValueOnce("tertiary");
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002", "FN-003"]));
renderListView({ tasks, onArchiveTask, onDeleteTask });
enterBulkEditMode();
await waitFor(() => expect(screen.queryAllByText("Shipped").length).toBeGreaterThan(0));
await user.click(screen.getByRole("button", { name: /delete selected/i }));
await waitFor(() => {
expect(onArchiveTask).toHaveBeenCalledTimes(1);
expect(onArchiveTask).toHaveBeenCalledWith("FN-001");
expect(onDeleteTask).toHaveBeenCalledTimes(1);
expect(onDeleteTask).toHaveBeenCalledWith("FN-002");
});
expect(mockAddToast).toHaveBeenCalledWith("Archived 1, deleted 1, failed 0", "success");
});
it("does nothing when delete confirm is cancelled", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001" })];

View File

@@ -213,6 +213,7 @@ describe("WorkflowNodeEditor", () => {
});
afterEach(() => {
localStorage.removeItem("fusion:wf-sidebar-settings-collapsed");
cleanup();
vi.clearAllMocks();
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { buildBoardWorkflowsPayload, DEFAULT_WORKFLOW_LANE_ID } from "../board-workflows.js";
import type { WorkflowDefinition } from "@fusion/core";
import { parseWorkflowIr } from "@fusion/core";
@@ -94,6 +94,27 @@ describe("buildBoardWorkflowsPayload", () => {
expect(payload.workflows.map((w) => w.id).sort()).toEqual([DEFAULT_WORKFLOW_LANE_ID, "wf-custom"]);
});
it("logs when workflow definition listing fails and falls back to referenced workflows", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const store = {
...makeStore({ flagOn: true, selections: {} }),
async listWorkflowDefinitions() {
throw new Error("db unavailable");
},
};
try {
const payload = await buildBoardWorkflowsPayload(store as never, ["FN-1"]);
expect(payload.workflows.map((w) => w.id)).toContain(DEFAULT_WORKFLOW_LANE_ID);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[board-workflows] listWorkflowDefinitions failed"),
expect.any(Error),
);
} finally {
warnSpy.mockRestore();
}
});
it("describes a custom workflow's columns with resolved trait flags", async () => {
const store = makeStore({
flagOn: true,

View File

@@ -183,9 +183,11 @@ export async function buildBoardWorkflowsPayload(
if (definition.kind === "fragment") continue;
referenced.add(definition.id);
}
} catch {
} catch (err) {
// Older/partial test stores may not expose definition listing; the referenced
// workflow set above is still sufficient for task rendering.
// workflow set above is still sufficient for task rendering. Production
// failures are logged so empty workflow definitions do not disappear silently.
console.warn("[board-workflows] listWorkflowDefinitions failed; using referenced workflows only", err);
}
const workflows: BoardWorkflowDefinition[] = [];

View File

@@ -1156,7 +1156,11 @@ describe("StuckTaskDetector", () => {
it("suppresses another loop classification while accepted recovery is pending", async () => {
const onLoopDetected = vi.fn().mockResolvedValue(true);
const customDetector = new StuckTaskDetector(store, { onLoopDetected });
const onStuck = vi.fn();
const customStore = createMockStore({
getSettings: vi.fn().mockResolvedValue({ taskStuckTimeoutMs: 60000 }),
});
const customDetector = new StuckTaskDetector(customStore, { onLoopDetected, onStuck });
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
@@ -1177,13 +1181,11 @@ describe("StuckTaskDetector", () => {
}
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBe("no-progress-churn");
customDetector.recordProgress("FN-201");
vi.advanceTimersByTime(61000);
for (let i = 0; i < 80; i++) {
customDetector.recordActivity("FN-201");
}
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBe("loop");
await customDetector.checkNow();
expect(onStuck).toHaveBeenCalledWith(expect.objectContaining({
taskId: "FN-201",
reason: "no-progress-churn",
}));
vi.useRealTimers();
});

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { Settings, TaskDetail, WorkflowDefinition, WorkflowIr } from "@fusion/core";
import { WorkflowGraphTaskRunner, type WorkflowGraphRunnerStore } from "../workflow-graph-task-runner.js";
@@ -165,9 +165,10 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
it("resolves built-in workflow selections without requiring the store to return a definition", async () => {
const calls: string[] = [];
const getWorkflowDefinition = vi.fn(async () => undefined);
const store: WorkflowGraphRunnerStore = {
getTaskWorkflowSelection: () => ({ workflowId: "builtin:coding", stepIds: [] }),
getWorkflowDefinition: async () => undefined,
getWorkflowDefinition,
};
const runner = new WorkflowGraphTaskRunner({
store,
@@ -180,6 +181,7 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["execute", "review", "merge"]);
expect(result.reason).toBeUndefined();
expect(getWorkflowDefinition).not.toHaveBeenCalled();
});
it("falls back (never strands the task) when the interpreter throws", async () => {

View File

@@ -649,9 +649,6 @@ export class StuckTaskDetector {
const stuckTasks: string[] = [];
for (const [taskId, entry] of this.tracked) {
if (entry.recoveryInProgress) {
continue;
}
const reason = this.classifyStuckReason(taskId, timeoutMs);
if (reason !== null) {
// U8: suppress flagging while the CLI session is waitingOnInput