FN-6941: add graph workflow filtering

Adds a Graph view header workflow dropdown that filters dependency graph tasks by the selected workflow.

- Portal the workflow switcher into the Graph view header and clear selection when unmounted.
- Scope plugin graph task context using workflow assignments and default workflow fallback.
- Cover the header integration, task filtering, portal lifecycle, and disabled workflow mode.
- Document Graph workflow filtering and add the published package changeset.

Files changed:
 .changeset/fn-6941-graph-workflow-dropdown.md      |   7 +
 docs/dashboard-guide.md                            |   1 +
 packages/dashboard/app/App.tsx                     |   4 +
 .../app/__tests__/graph-workflow-header.test.tsx   | 127 +++++++++++++++
 .../app/components/GraphWorkflowSwitcherSlot.tsx   | 110 +++++++++++++
 .../__tests__/GraphWorkflowSwitcherSlot.test.tsx   | 173 +++++++++++++++++++++
 .../app/components/dashboard/MainContent.tsx       |  23 ++-
 .../dashboard/app/components/dashboard/types.ts    |   3 +
 8 files changed, 446 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-6941

Fusion-Task-Lineage: a77bc99e-00fe-42fe-a976-d88ffd803388
This commit is contained in:
gsxdsm
2026-06-24 21:01:23 -07:00
parent 533c8af4bf
commit d7f3c7093e
8 changed files with 446 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a workflow dropdown to filter tasks in the dependency Graph view.
category: feature
dev: Scopes plugin-hosted graph tasks through the dashboard workflow assignment payload.

View File

@@ -173,6 +173,7 @@ Navigation:
Behavior:
- Shows only tasks in `triage`, `todo`, `in-progress`, and `in-review`
- Excludes `done` and `archived`
- On desktop/tablet, the header workflow dropdown mirrors Board/List selection behavior and filters graph nodes to tasks assigned to the selected workflow; **All workflows** restores the full active-task graph.
- Uses Sugiyama-style layered auto-layout to place nodes by dependency depth
- Renders directed bezier dependency edges (dependent → dependency) with arrowheads
- Supports cursor-centered wheel zoom, pinch zoom, keyboard shortcuts (`Ctrl/Cmd+=`, `Ctrl/Cmd+-`, `Ctrl/Cmd+0`, `Ctrl/Cmd+Shift+F`, `Escape`), and fit/reset controls via the floating toolbar with live zoom percentage

View File

@@ -107,6 +107,7 @@ import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog";
import { MainContent } from "./components/dashboard/MainContent";
import { DashboardBanners } from "./components/dashboard/DashboardBanners";
import type { DashboardBannersProps, MainContentProps } from "./components/dashboard/types";
import type { GraphWorkflowSelection } from "./components/GraphWorkflowSwitcherSlot";
// ChatView's CSS is imported eagerly so the styles bundle into the main
// CSS file. Without this, the lazy ChatView JS chunk loaded its own CSS
@@ -399,6 +400,7 @@ function AppInner() {
}, [taskView, refreshTasks]);
const boardSourceTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
const [graphWorkflowSelection, setGraphWorkflowSelection] = useState<GraphWorkflowSelection | null>(null);
const [researchReadinessVersion, setResearchReadinessVersion] = useState(0);
const mountTimeRef = useRef(performance.now());
@@ -1119,6 +1121,8 @@ function AppInner() {
handleRemoveProject,
nodes,
graphPluginTaskView,
graphWorkflowSelection,
setGraphWorkflowSelection,
isRemote,
remoteData,
tasks,

View File

@@ -0,0 +1,127 @@
import { useState } from "react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api";
import {
filterTasksByGraphWorkflowSelection,
GraphWorkflowSwitcherSlot,
type GraphWorkflowSelection,
} from "../components/GraphWorkflowSwitcherSlot";
const fetchBoardWorkflowsMock = vi.fn();
const subscribeSseMock = vi.fn(() => vi.fn());
vi.mock("../api", () => ({
fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args),
}));
vi.mock("../sse-bus", () => ({
subscribeSse: (...args: unknown[]) => subscribeSseMock(...args),
}));
const DEFAULT_WORKFLOW: BoardWorkflowDefinition = {
id: "builtin:coding",
name: "Coding",
columns: [],
};
const REVIEW_WORKFLOW: BoardWorkflowDefinition = {
id: "wf-review",
name: "Review",
columns: [],
};
const TASKS = [
{ id: "FN-default", title: "Default task" },
{ id: "FN-unassigned", title: "Unassigned task" },
{ id: "FN-review", title: "Review task" },
{ id: "FN-unknown", title: "Unknown task" },
];
function workflowPayload(overrides: Partial<BoardWorkflowsPayload> = {}): BoardWorkflowsPayload {
return {
flagEnabled: true,
defaultWorkflowId: DEFAULT_WORKFLOW.id,
workflows: [DEFAULT_WORKFLOW, REVIEW_WORKFLOW],
taskWorkflowIds: {
"FN-review": REVIEW_WORKFLOW.id,
"FN-unknown": "wf-missing",
},
...overrides,
};
}
function GraphPluginContextHarness({ projectId = "project-graph" }: { projectId?: string }) {
const [selection, setSelection] = useState<GraphWorkflowSelection | null>(null);
const pluginTasks = filterTasksByGraphWorkflowSelection(TASKS, projectId, selection);
return (
<>
<div id="header-workflow-slot" data-testid="header-workflow-slot" />
<GraphWorkflowSwitcherSlot projectId={projectId} onWorkflowSelectionChange={setSelection} />
<ul data-testid="graph-plugin-context-tasks">
{pluginTasks.map((task) => (
<li key={task.id} data-testid={`graph-context-task-${task.id}`}>{task.title}</li>
))}
</ul>
</>
);
}
beforeEach(() => {
sessionStorage.clear();
fetchBoardWorkflowsMock.mockReset();
subscribeSseMock.mockClear();
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload());
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Graph workflow header integration", () => {
it("ports the dropdown into the header and scopes graph plugin tasks by selected workflow", async () => {
render(<GraphPluginContextHarness />);
const headerSlot = screen.getByTestId("header-workflow-slot");
const selector = await screen.findByTestId("workflow-switcher");
expect(headerSlot.contains(selector)).toBe(true);
const contextTasks = screen.getByTestId("graph-plugin-context-tasks");
await waitFor(() => {
expect(within(contextTasks).getByTestId("graph-context-task-FN-default")).toBeInTheDocument();
expect(within(contextTasks).getByTestId("graph-context-task-FN-unassigned")).toBeInTheDocument();
expect(within(contextTasks).queryByTestId("graph-context-task-FN-review")).toBeNull();
expect(within(contextTasks).queryByTestId("graph-context-task-FN-unknown")).toBeNull();
});
fireEvent.click(selector);
fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-review"));
await waitFor(() => {
expect(within(contextTasks).getByTestId("graph-context-task-FN-review")).toBeInTheDocument();
expect(within(contextTasks).queryByTestId("graph-context-task-FN-default")).toBeNull();
expect(within(contextTasks).queryByTestId("graph-context-task-FN-unassigned")).toBeNull();
expect(within(contextTasks).queryByTestId("graph-context-task-FN-unknown")).toBeNull();
});
});
it("keeps graph plugin tasks unfiltered when workflow mode is disabled or no project is selected", async () => {
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ flagEnabled: false, workflows: [] }));
const { rerender } = render(<GraphPluginContextHarness />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-graph"));
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
for (const task of TASKS) {
expect(screen.getByTestId(`graph-context-task-${task.id}`)).toBeInTheDocument();
}
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload());
rerender(<GraphPluginContextHarness projectId="" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith(""));
for (const task of TASKS) {
expect(screen.getByTestId(`graph-context-task-${task.id}`)).toBeInTheDocument();
}
});
});

View File

@@ -0,0 +1,110 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
import { useViewportMode } from "../hooks/useViewportMode";
import { WorkflowSwitcher } from "./WorkflowSwitcher";
import type { WorkflowStatusCounts } from "./workflowStatusCounts";
export interface GraphWorkflowSelection {
boardWorkflows: BoardWorkflowsPayload;
selectedWorkflow: BoardWorkflowDefinition;
}
interface GraphWorkflowSwitcherSlotProps {
projectId?: string;
onOpenWorkflowEditor?: () => void;
onCreateWorkflow?: () => void;
onWorkflowSelectionChange?: (selection: GraphWorkflowSelection | null) => void;
}
const EMPTY_COUNTS: Map<string, WorkflowStatusCounts> = new Map();
export function filterTasksByGraphWorkflowSelection<T extends { id: string }>(
tasks: T[],
projectId: string | undefined,
selection: GraphWorkflowSelection | null,
): T[] {
if (!projectId || !selection) return tasks;
return tasks.filter((task) => {
const assignedWorkflowId = selection.boardWorkflows.taskWorkflowIds[task.id]
?? selection.boardWorkflows.defaultWorkflowId;
return assignedWorkflowId === selection.selectedWorkflow.id;
});
}
export function GraphWorkflowSwitcherSlot({
projectId,
onOpenWorkflowEditor,
onCreateWorkflow,
onWorkflowSelectionChange,
}: GraphWorkflowSwitcherSlotProps) {
const {
boardWorkflows,
workflowMode,
workflowOptions,
selectedWorkflow,
setSelectedWorkflowId,
refreshBoardWorkflows,
} = useBoardWorkflows({ projectId });
const viewportMode = useViewportMode();
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
if (typeof document === "undefined") return null;
return document.getElementById("header-workflow-slot");
});
useEffect(() => {
if (typeof document === "undefined") return;
const resolve = () => {
const slot = document.getElementById("header-workflow-slot");
setHeaderWorkflowSlot((previous) => (previous === slot ? previous : slot));
return slot;
};
if (resolve()) return;
/*
FNXC:GraphWorkflowSwitcher 2026-06-23-21:45:
Graph shares the Board/List header workflow affordance, but mobile and inactive left-sidebar layouts can omit `#header-workflow-slot`. Poll only briefly and re-resolve on viewport changes so Graph never spins forever or leaves an empty dropdown shell when the header slot is absent.
*/
let attempts = 0;
const interval = window.setInterval(() => {
attempts += 1;
if (resolve() || attempts >= 20) window.clearInterval(interval);
}, 250);
return () => window.clearInterval(interval);
}, [viewportMode]);
const selection = useMemo<GraphWorkflowSelection | null>(() => {
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null;
return { boardWorkflows, selectedWorkflow };
}, [boardWorkflows, selectedWorkflow, workflowMode]);
useEffect(() => {
onWorkflowSelectionChange?.(selection);
}, [onWorkflowSelectionChange, selection]);
useEffect(() => {
return () => onWorkflowSelectionChange?.(null);
}, [onWorkflowSelectionChange]);
if (!workflowMode || !selectedWorkflow || workflowOptions.length < 2 || !headerWorkflowSlot) {
return null;
}
return createPortal(
<div className="board-workflow-toolbar">
<div className="board-workflow-selector">
<WorkflowSwitcher
workflows={workflowOptions}
value={selectedWorkflow.id}
onChange={setSelectedWorkflowId}
counts={EMPTY_COUNTS}
onOpen={refreshBoardWorkflows}
onEditWorkflow={onOpenWorkflowEditor}
onCreateWorkflow={onCreateWorkflow}
/>
</div>
</div>,
headerWorkflowSlot,
);
}

View File

@@ -0,0 +1,173 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../../api";
import { filterTasksByGraphWorkflowSelection, GraphWorkflowSwitcherSlot, type GraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot";
const fetchBoardWorkflowsMock = vi.fn();
const subscribeSseMock = vi.fn(() => vi.fn());
vi.mock("../../api", () => ({
fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: (...args: unknown[]) => subscribeSseMock(...args),
}));
const DEFAULT_WORKFLOW: BoardWorkflowDefinition = {
id: "builtin:coding",
name: "Coding",
columns: [],
};
const CUSTOM_WORKFLOW: BoardWorkflowDefinition = {
id: "wf-review",
name: "Review",
columns: [],
};
function workflowPayload(overrides: Partial<BoardWorkflowsPayload> = {}): BoardWorkflowsPayload {
return {
flagEnabled: true,
defaultWorkflowId: DEFAULT_WORKFLOW.id,
workflows: [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
taskWorkflowIds: {},
...overrides,
};
}
function appendHeaderWorkflowSlot() {
const slot = document.createElement("div");
slot.id = "header-workflow-slot";
slot.className = "header-workflow-slot";
document.body.appendChild(slot);
return slot;
}
beforeEach(() => {
sessionStorage.clear();
fetchBoardWorkflowsMock.mockReset();
subscribeSseMock.mockClear();
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload());
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
});
afterEach(() => {
document.getElementById("header-workflow-slot")?.remove();
vi.restoreAllMocks();
});
describe("filterTasksByGraphWorkflowSelection", () => {
it("uses task workflow assignments with default fallback for graph scoping", () => {
const tasks = [
{ id: "FN-default" },
{ id: "FN-unassigned" },
{ id: "FN-review" },
{ id: "FN-unknown" },
];
const selection: GraphWorkflowSelection = {
boardWorkflows: workflowPayload({
taskWorkflowIds: {
"FN-review": CUSTOM_WORKFLOW.id,
"FN-unknown": "wf-missing",
},
}),
selectedWorkflow: DEFAULT_WORKFLOW,
};
expect(filterTasksByGraphWorkflowSelection(tasks, "project-graph", selection).map((task) => task.id)).toEqual([
"FN-default",
"FN-unassigned",
]);
expect(filterTasksByGraphWorkflowSelection(tasks, "project-graph", { ...selection, selectedWorkflow: CUSTOM_WORKFLOW }).map((task) => task.id)).toEqual([
"FN-review",
]);
});
it("preserves unfiltered graph tasks without a project or workflow payload", () => {
const tasks = [{ id: "FN-a" }, { id: "FN-b" }];
const selection: GraphWorkflowSelection = {
boardWorkflows: workflowPayload({ taskWorkflowIds: { "FN-b": CUSTOM_WORKFLOW.id } }),
selectedWorkflow: CUSTOM_WORKFLOW,
};
expect(filterTasksByGraphWorkflowSelection(tasks, undefined, selection)).toBe(tasks);
expect(filterTasksByGraphWorkflowSelection(tasks, "project-graph", null)).toBe(tasks);
});
});
describe("GraphWorkflowSwitcherSlot", () => {
it("portals the shared workflow switcher into the header workflow slot", async () => {
const headerSlot = appendHeaderWorkflowSlot();
const onWorkflowSelectionChange = vi.fn();
render(<GraphWorkflowSwitcherSlot projectId="project-graph" onWorkflowSelectionChange={onWorkflowSelectionChange} />);
const selector = await screen.findByTestId("workflow-switcher");
expect(headerSlot.contains(selector)).toBe(true);
expect(headerSlot.querySelector(".board-workflow-toolbar")).not.toBeNull();
await waitFor(() => {
expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith({
boardWorkflows: workflowPayload(),
selectedWorkflow: DEFAULT_WORKFLOW,
});
});
});
it("refreshes the board-workflows payload when the dropdown opens", async () => {
appendHeaderWorkflowSlot();
render(<GraphWorkflowSwitcherSlot projectId="project-refresh" />);
const selector = await screen.findByTestId("workflow-switcher");
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(1));
fireEvent.click(selector);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(2));
expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument();
});
it("reports selection changes so App can scope graph tasks", async () => {
appendHeaderWorkflowSlot();
const onWorkflowSelectionChange = vi.fn();
render(<GraphWorkflowSwitcherSlot projectId="project-select" onWorkflowSelectionChange={onWorkflowSelectionChange} />);
fireEvent.click(await screen.findByTestId("workflow-switcher"));
fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-review"));
await waitFor(() => {
const lastSelection = onWorkflowSelectionChange.mock.calls.at(-1)?.[0] as GraphWorkflowSelection | null;
expect(lastSelection?.selectedWorkflow.id).toBe("wf-review");
});
});
it("renders no dropdown shell when the header slot is absent", async () => {
render(<GraphWorkflowSwitcherSlot projectId="project-no-slot" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalled());
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
expect(document.querySelector(".board-workflow-toolbar")).toBeNull();
});
it("renders no dropdown shell when workflow mode is disabled, empty, or not switchable", async () => {
const headerSlot = appendHeaderWorkflowSlot();
const { rerender } = render(<GraphWorkflowSwitcherSlot projectId="project-disabled" />);
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ flagEnabled: false, workflows: [] }));
rerender(<GraphWorkflowSwitcherSlot projectId="project-disabled-next" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-disabled-next"));
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
expect(headerSlot.childElementCount).toBe(0);
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ workflows: [] }));
rerender(<GraphWorkflowSwitcherSlot projectId="project-empty" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-empty"));
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
expect(headerSlot.childElementCount).toBe(0);
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ workflows: [DEFAULT_WORKFLOW] }));
rerender(<GraphWorkflowSwitcherSlot projectId="project-single" />);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-single"));
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
expect(headerSlot.childElementCount).toBe(0);
});
});

View File

@@ -16,6 +16,7 @@ import { BackendConnectionErrorPage } from "../BackendConnectionErrorPage";
import { CapacityRiskBanner } from "../CapacityRiskBanner";
import { PlanningModeModal } from "../PlanningModeModal";
import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot";
import { GraphWorkflowSwitcherSlot, filterTasksByGraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot";
import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost";
import { isPluginViewId } from "../../plugins/pluginViewRegistry";
import { isNearDuplicateCanonicalInactive } from "../../../../core/src/near-duplicate-canonical";
@@ -57,6 +58,8 @@ export function MainContent({
handleRemoveProject,
nodes,
graphPluginTaskView,
graphWorkflowSelection,
setGraphWorkflowSelection,
isRemote,
remoteData,
tasks,
@@ -241,13 +244,29 @@ export function MainContent({
// Project view
if (resolvedPluginTaskView) {
const pluginTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
const isDependencyGraphView = resolvedPluginTaskView === "plugin:fusion-plugin-dependency-graph:graph";
/*
FNXC:GraphWorkflowSwitcher 2026-06-23-22:04:
The dependency Graph is plugin-hosted, so App scopes the normal `tasks` array before it enters PluginDashboardViewHost instead of teaching the graph plugin about workflow metadata. This preserves the plugin context contract while matching Board/List workflow assignment fallback: `taskWorkflowIds[task.id] ?? defaultWorkflowId` must equal the selected header workflow.
*/
const pluginContextTasks = isDependencyGraphView
? filterTasksByGraphWorkflowSelection(pluginTasks, currentProject?.id, graphWorkflowSelection)
: pluginTasks;
return (
<PageErrorBoundary>
{isDependencyGraphView ? (
<GraphWorkflowSwitcherSlot
projectId={currentProject?.id}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onCreateWorkflow={openCreateWorkflowWithNav}
onWorkflowSelectionChange={setGraphWorkflowSelection}
/>
) : null}
<PluginDashboardViewHost
taskView={resolvedPluginTaskView as `plugin:${string}:${string}`}
context={{
projectId: currentProject?.id,
tasks: pluginTasks,
tasks: pluginContextTasks,
workflowSteps,
subscribePluginEvents,
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTask(task, initialTab),
@@ -263,7 +282,7 @@ export function MainContent({
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMerge}
nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string"
? isNearDuplicateCanonicalInactive(pluginTasks.find((candidate) => candidate.id === task.sourceMetadata?.nearDuplicateOf))
? isNearDuplicateCanonicalInactive(pluginContextTasks.find((candidate) => candidate.id === task.sourceMetadata?.nearDuplicateOf))
: undefined}
/>
),

View File

@@ -37,6 +37,7 @@ import type { UseRemoteNodeDataResult } from "../../hooks/useRemoteNodeData";
import type { SectionId } from "../SettingsModal";
import type { CliActionId } from "../SessionNotificationBanner";
import type { ApprovalBannerCandidate } from "../../utils/appLifecycle";
import type { GraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot";
// The lazy view components are value exports; importing them as values lets us
// spell their types via `typeof` so MainContent's JSX gets full prop checking.
import { SettingsView } from "../SettingsModal";
@@ -91,6 +92,8 @@ export interface MainContentProps {
handleRemoveProject: (project: ProjectInfo) => Promise<void>;
nodes: NodeInfo[];
graphPluginTaskView: PluginTaskView | null;
graphWorkflowSelection: GraphWorkflowSelection | null;
setGraphWorkflowSelection: Dispatch<SetStateAction<GraphWorkflowSelection | null>>;
isRemote: boolean;
remoteData: UseRemoteNodeDataResult;
tasks: Task[];