From ffa59c24819617eeceb0099a15405a59515eb0aa Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 8 Jun 2026 16:56:00 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20workflow=20vis?= =?UTF-8?q?ibility=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement shared workflow visibility state, Workflows sidebar, topbar indicator, chat-card integration, workflow definition precedence, and tests.\n\n---\n\n_Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `07.49`_\n\n --- .../WorkflowIndicator.test.tsx | 131 ++++++ .../WorkflowIndicator/WorkflowIndicator.tsx | 139 ++++++ .../WorkspaceMenuBar/WorkspaceMenuBar.tsx | 2 + .../features/RightSidebar/RightSidebar.tsx | 15 + .../features/RightSidebar/Tabs/TabLabels.tsx | 37 ++ .../features/RightSidebar/Tabs/tabConfig.ts | 6 + .../RightSidebar/Tabs/tabRegistry.tsx | 10 + .../features/Tools/WorkflowRunToolCall.tsx | 41 +- .../features/Workflows/WorkflowStore.test.ts | 145 ++++++ .../features/Workflows/WorkflowStore.ts | 333 ++++++++++++++ .../features/Workflows/WorkflowsTab.test.tsx | 212 +++++++++ .../features/Workflows/WorkflowsTab.tsx | 411 ++++++++++++++++++ .../workflowStatusPresentation.test.ts | 84 ++++ .../Workflows/workflowStatusPresentation.ts | 155 +++++++ src/common/constants/events.ts | 9 + .../workflows/WorkflowDefinitionStore.test.ts | 25 ++ .../workflows/WorkflowDefinitionStore.ts | 39 +- 17 files changed, 1760 insertions(+), 34 deletions(-) create mode 100644 src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx create mode 100644 src/browser/components/WorkflowIndicator/WorkflowIndicator.tsx create mode 100644 src/browser/features/Workflows/WorkflowStore.test.ts create mode 100644 src/browser/features/Workflows/WorkflowStore.ts create mode 100644 src/browser/features/Workflows/WorkflowsTab.test.tsx create mode 100644 src/browser/features/Workflows/WorkflowsTab.tsx create mode 100644 src/browser/features/Workflows/workflowStatusPresentation.test.ts create mode 100644 src/browser/features/Workflows/workflowStatusPresentation.ts diff --git a/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx b/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx new file mode 100644 index 0000000000..138eea1bd1 --- /dev/null +++ b/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { installDom } from "../../../../tests/ui/dom"; + +import type { WorkflowDefinitionDescriptor, WorkflowRunRecord } from "@/common/types/workflow"; +import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; +import { WorkflowIndicatorView } from "./WorkflowIndicator"; +import { + groupWorkflowDefinitionsByScope, + summarizeWorkflowRuns, +} from "@/browser/features/Workflows/workflowStatusPresentation"; + +function definition( + name: string, + scope: WorkflowDefinitionDescriptor["scope"] +): WorkflowDefinitionDescriptor { + return { name, scope, description: `${name} workflow`, executable: true }; +} + +function run(overrides: Partial): WorkflowRunRecord { + return { + id: "wfr_test", + workspaceId: "workspace-1", + definition: definition("demo", "project"), + definitionSource: "/repo/.mux/workflows/demo.js", + definitionHash: "hash", + args: {}, + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + events: [], + steps: [], + ...overrides, + }; +} + +describe("WorkflowIndicatorView", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("prioritizes problem runs over active counts", () => { + const definitions = [definition("project-flow", "project")]; + const runs = [ + run({ id: "wfr_failed", status: "failed", definition: definitions[0] }), + run({ id: "wfr_running", status: "running" }), + ]; + const view = render( + + + + ); + + const button = view.getByLabelText("1 workflow needs attention"); + expect(button.textContent).toContain("1"); + }); + + test("uses plural grammar for multiple problem runs", () => { + const failed = run({ id: "wfr_failed", status: "failed" }); + const interrupted = run({ id: "wfr_interrupted", status: "interrupted" }); + const runs = [failed, interrupted]; + const view = render( + + + + ); + + expect(view.getByLabelText("2 workflows need attention")).toBeTruthy(); + }); + + test("opens the workflows tab from the popover action", () => { + let opened = false; + const view = render( + + { + opened = true; + }} + snapshot={{ + definitions: [], + definitionGroups: groupWorkflowDefinitionsByScope([]), + runs: [], + currentRuns: [], + historyRuns: [], + summary: summarizeWorkflowRuns([]), + isLoading: false, + error: null, + }} + /> + + ); + + fireEvent.click(view.getByLabelText("Workflows")); + fireEvent.click(view.getByText("Open tab")); + + expect(opened).toBe(true); + }); +}); diff --git a/src/browser/components/WorkflowIndicator/WorkflowIndicator.tsx b/src/browser/components/WorkflowIndicator/WorkflowIndicator.tsx new file mode 100644 index 0000000000..b322a9e38e --- /dev/null +++ b/src/browser/components/WorkflowIndicator/WorkflowIndicator.tsx @@ -0,0 +1,139 @@ +import React from "react"; +import { Workflow } from "lucide-react"; + +import { Popover, PopoverContent, PopoverTrigger } from "@/browser/components/Popover/Popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/Tooltip/Tooltip"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; +import { cn } from "@/common/lib/utils"; + +import { + useWorkflowWorkspaceSnapshot, + type WorkflowWorkspaceSnapshot, +} from "@/browser/features/Workflows/WorkflowStore"; +import { getWorkflowStatusPresentation } from "@/browser/features/Workflows/workflowStatusPresentation"; + +interface WorkflowIndicatorProps { + workspaceId: string; +} + +interface WorkflowIndicatorViewProps { + workspaceId: string; + snapshot: WorkflowWorkspaceSnapshot; + onOpenWorkflowsTab?: () => void; +} + +export function WorkflowIndicator(props: WorkflowIndicatorProps) { + const snapshot = useWorkflowWorkspaceSnapshot(props.workspaceId); + return ; +} + +export function WorkflowIndicatorView(props: WorkflowIndicatorViewProps) { + const [open, setOpen] = React.useState(false); + const problemCount = props.snapshot.summary.problemCount; + const activeCount = props.snapshot.summary.activeCount; + const hasProblems = problemCount > 0; + const hasActive = activeCount > 0; + const badgeCount = hasProblems ? problemCount : activeCount; + const label = hasProblems + ? `${problemCount} workflow${problemCount === 1 ? "" : "s"} ${problemCount === 1 ? "needs" : "need"} attention` + : hasActive + ? `${activeCount} active workflow${activeCount === 1 ? "" : "s"}` + : "Workflows"; + + const openWorkflowsTab = () => { + setOpen(false); + if (props.onOpenWorkflowsTab) { + props.onOpenWorkflowsTab(); + return; + } + window.dispatchEvent( + createCustomEvent(CUSTOM_EVENTS.OPEN_WORKFLOWS_TAB, { workspaceId: props.workspaceId }) + ); + }; + + return ( + + + + + + + + + {label} + + + +
+
+
Workflows
+ +
+ + {props.snapshot.currentRuns.length === 0 ? ( +

No active workflows

+ ) : ( + props.snapshot.currentRuns.slice(0, 5).map((run) => { + const presentation = getWorkflowStatusPresentation(run.status); + return ( +
+ {run.definition.name} + {presentation.label} +
+ ); + }) + )} +
+ + {props.snapshot.definitions.length === 0 ? ( +

No definitions found

+ ) : ( +

+ {props.snapshot.definitionGroups.project.length} project ·{" "} + {props.snapshot.definitionGroups.global.length} global ·{" "} + {props.snapshot.definitionGroups["built-in"].length} built-in ·{" "} + {props.snapshot.definitionGroups.scratch.length} scratch +

+ )} +
+
+
+
+ ); +} + +function IndicatorSection(props: { title: string; children: React.ReactNode }) { + return ( +
+

+ {props.title} +

+ {props.children} +
+ ); +} diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx index ec33625d68..f2ced227b0 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx @@ -44,6 +44,7 @@ import { PopoverError } from "../PopoverError/PopoverError"; import { WorkspaceActionsMenuContent } from "../WorkspaceActionsMenuContent/WorkspaceActionsMenuContent"; import { WorkspaceTerminalIcon } from "../icons/WorkspaceTerminalIcon/WorkspaceTerminalIcon"; +import { WorkflowIndicator } from "../WorkflowIndicator/WorkflowIndicator"; import { SkillIndicator } from "../SkillIndicator/SkillIndicator"; import { useAPI } from "@/browser/contexts/API"; import { useAgent } from "@/browser/contexts/AgentContext"; @@ -683,6 +684,7 @@ export const WorkspaceMenuBar: React.FC = ({ + = ({ return () => window.removeEventListener(CUSTOM_EVENTS.OPEN_GOAL_TAB, handleOpenGoalTab); }, [setCollapsed, setLayout, workspaceId]); + React.useEffect(() => { + const handleOpenWorkflowsTab = (event: Event) => { + const detail = (event as CustomEvent<{ workspaceId: string }>).detail; + if (detail?.workspaceId !== workspaceId) { + return; + } + setCollapsed(false); + setLayout((prev) => selectOrAddTab(prev, "workflows")); + }; + + window.addEventListener(CUSTOM_EVENTS.OPEN_WORKFLOWS_TAB, handleOpenWorkflowsTab); + return () => + window.removeEventListener(CUSTOM_EVENTS.OPEN_WORKFLOWS_TAB, handleOpenWorkflowsTab); + }, [setCollapsed, setLayout, workspaceId]); + React.useEffect(() => { const handleOpenTouchReviewImmersive = (event: Event) => { const detail = (event as CustomEvent<{ workspaceId: string }>).detail; diff --git a/src/browser/features/RightSidebar/Tabs/TabLabels.tsx b/src/browser/features/RightSidebar/Tabs/TabLabels.tsx index e8921801c5..1a6b102221 100644 --- a/src/browser/features/RightSidebar/Tabs/TabLabels.tsx +++ b/src/browser/features/RightSidebar/Tabs/TabLabels.tsx @@ -15,6 +15,7 @@ import { Globe, Sparkles, Target, + Workflow, Terminal as TerminalIcon, X, } from "lucide-react"; @@ -32,6 +33,7 @@ import { useOptionalWorkspaceSidebarState, useWorkspaceUsage, } from "@/browser/stores/WorkspaceStore"; +import { useWorkflowWorkspaceSummary } from "@/browser/features/Workflows/WorkflowStore"; import { goalActiveMode, isGoalPendingPersistence } from "@/common/types/goal"; import { sumUsageHistory, type ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; @@ -221,6 +223,41 @@ export const GoalTabLabel: React.FC = ({ workspaceId }) => { ); }; +interface WorkflowsTabLabelProps { + workspaceId: string; +} + +export const WorkflowsTabLabel: React.FC = ({ workspaceId }) => { + const summary = useWorkflowWorkspaceSummary(workspaceId); + const hasProblems = summary.problemCount > 0; + const hasActive = summary.activeCount > 0; + + return ( + + + Workflows + {(hasProblems || hasActive) && ( + + {hasProblems ? summary.problemCount : summary.activeCount} + + )} + + ); +}; + export function OutputTabLabel() { return <>Output; } diff --git a/src/browser/features/RightSidebar/Tabs/tabConfig.ts b/src/browser/features/RightSidebar/Tabs/tabConfig.ts index 105558a3ec..50a570b0f3 100644 --- a/src/browser/features/RightSidebar/Tabs/tabConfig.ts +++ b/src/browser/features/RightSidebar/Tabs/tabConfig.ts @@ -53,6 +53,12 @@ const TAB_CONFIG_DEF = { defaultOrder: 35, paletteKeywords: ["goal", "target", "objective"], }, + workflows: { + name: "Workflows", + contentClassName: "overflow-hidden p-0", + defaultOrder: 37, + paletteKeywords: ["workflow", "workflows", "automation", "run"], + }, desktop: { name: "Desktop", contentClassName: "overflow-hidden p-0", diff --git a/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx b/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx index 395b6e7168..499af13585 100644 --- a/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx +++ b/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx @@ -22,6 +22,7 @@ import { DesktopPanel } from "@/browser/features/desktop/DesktopPanel"; import { BrowserTab } from "@/browser/features/RightSidebar/BrowserTab"; import { DevToolsTab } from "@/browser/features/RightSidebar/DevToolsTab"; import { GoalTab, type GoalCreateIntent } from "@/browser/features/RightSidebar/GoalTab"; +import { WorkflowsTab } from "@/browser/features/Workflows/WorkflowsTab"; import type { GoalSnapshot, GoalStatus } from "@/common/types/goal"; import type { ReviewNoteData } from "@/common/types/review"; import { BASE_TAB_IDS, TAB_CONFIG, type BaseTabType, type TabConfig } from "./tabConfig"; @@ -31,6 +32,7 @@ import { DesktopTabLabel, GoalTabLabel, InstructionsTabLabel, + WorkflowsTabLabel, OutputTabLabel, ReviewTabLabel, StatsTabLabel, @@ -157,6 +159,14 @@ const TAB_RENDERERS = { ), }, + workflows: { + Label: ({ workspaceId }) => , + renderPanel: (ctx) => ( + + + + ), + }, desktop: { Label: DesktopTabLabel, renderPanel: (ctx) => ( diff --git a/src/browser/features/Tools/WorkflowRunToolCall.tsx b/src/browser/features/Tools/WorkflowRunToolCall.tsx index c73a6ec298..cb76daddaf 100644 --- a/src/browser/features/Tools/WorkflowRunToolCall.tsx +++ b/src/browser/features/Tools/WorkflowRunToolCall.tsx @@ -60,6 +60,7 @@ import { formatWorkflowSavedMessage, type WorkflowPromotionTarget, } from "./WorkflowDefinitionToolCall"; +import { useWorkflowWorkspaceSnapshot } from "@/browser/features/Workflows/WorkflowStore"; import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { MarkdownRenderer } from "../Messages/MarkdownRenderer"; @@ -885,24 +886,22 @@ function selectWorkflowRunSnapshot(input: { runId?: string; baseRun?: WorkflowRunRecord; refreshedRun: WorkflowRunRecord | null; + storeRun?: WorkflowRunRecord; }): { runId?: string; run?: WorkflowRunRecord } { - const runId = input.runId ?? input.baseRun?.id ?? input.refreshedRun?.id; + const runId = input.runId ?? input.baseRun?.id ?? input.refreshedRun?.id ?? input.storeRun?.id; if (runId == null) { return {}; } - if (input.refreshedRun?.id !== runId) { - return input.baseRun == null ? { runId } : { runId, run: input.baseRun }; - } - if (input.baseRun?.id !== runId) { - return { runId, run: input.refreshedRun }; + const candidates = [input.baseRun, input.refreshedRun, input.storeRun].filter( + (candidate): candidate is WorkflowRunRecord => candidate?.id === runId + ); + if (candidates.length === 0) { + return { runId }; } - return { - runId, - run: - compareWorkflowRunSnapshots(input.refreshedRun, input.baseRun) >= 0 - ? input.refreshedRun - : input.baseRun, - }; + const run = candidates.reduce((newest, candidate) => + compareWorkflowRunSnapshots(newest, candidate) >= 0 ? newest : candidate + ); + return { runId, run }; } function getLatestResultEvent(run: WorkflowRunRecord | null | undefined): unknown { @@ -955,10 +954,14 @@ export const WorkflowRunToolCall: React.FC = ({ setWorkflowActionInFlightRunId(nextRunId); }; const baseRun = successResult?.run; + const workflowSnapshot = useWorkflowWorkspaceSnapshot(workspaceId); + const snapshotRunId = successResult?.runId ?? baseRun?.id ?? refreshedRun?.id; + const storeRun = workflowSnapshot.runs.find((candidate) => candidate.id === snapshotRunId); const selectedRun = selectWorkflowRunSnapshot({ runId: successResult?.runId, baseRun, refreshedRun, + storeRun, }); const runId = selectedRun.runId; const run = selectedRun.run; @@ -1251,7 +1254,7 @@ export const WorkflowRunToolCall: React.FC = ({ apiState?.api == null || runId == null || run?.workspaceId == null || - (!shouldRefreshWorkflow(displayStatus) && resumingRunId !== runId) + (resumingRunId !== runId && (storeRun != null || !shouldRefreshWorkflow(displayStatus))) ) { return; } @@ -1288,7 +1291,15 @@ export const WorkflowRunToolCall: React.FC = ({ ignore = true; window.clearInterval(interval); }; - }, [apiState?.api, displayEventSequence, displayStatus, resumingRunId, run?.workspaceId, runId]); + }, [ + apiState?.api, + displayEventSequence, + displayStatus, + resumingRunId, + run?.workspaceId, + runId, + storeRun, + ]); return ( diff --git a/src/browser/features/Workflows/WorkflowStore.test.ts b/src/browser/features/Workflows/WorkflowStore.test.ts new file mode 100644 index 0000000000..ea7df20f93 --- /dev/null +++ b/src/browser/features/Workflows/WorkflowStore.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; + +import type { APIClient } from "@/browser/contexts/API"; +import type { WorkflowDefinitionDescriptor, WorkflowRunRecord } from "@/common/types/workflow"; + +import { WORKFLOW_RUN_POLL_INTERVAL_MS, WorkflowStore } from "./WorkflowStore"; + +function definition( + name: string, + scope: WorkflowDefinitionDescriptor["scope"] +): WorkflowDefinitionDescriptor { + return { name, scope, description: `${name} workflow`, executable: true }; +} + +function run(overrides: Partial): WorkflowRunRecord { + return { + id: "wfr_test", + workspaceId: "workspace-1", + definition: definition("demo", "project"), + definitionSource: "/repo/.mux/workflows/demo.js", + definitionHash: "hash", + args: {}, + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + events: [], + steps: [], + ...overrides, + }; +} + +function createClient(input: { + definitions?: WorkflowDefinitionDescriptor[]; + runs?: WorkflowRunRecord[]; + runDetails?: WorkflowRunRecord[]; +}) { + const calls = { + listDefinitions: mock(() => Promise.resolve(input.definitions ?? [])), + listRuns: mock(() => Promise.resolve(input.runs ?? [])), + getRun: mock(() => Promise.resolve(input.runDetails?.shift() ?? input.runs?.[0] ?? null)), + }; + const client = { + workflows: { + listDefinitions: calls.listDefinitions, + listRuns: calls.listRuns, + getRun: calls.getRun, + }, + } as unknown as APIClient; + return { client, calls }; +} + +async function waitForStoreSnapshot(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + if (predicate()) return; + await Bun.sleep(1); + } + throw new Error("Timed out waiting for workflow store snapshot"); +} + +describe("WorkflowStore", () => { + afterEach(() => { + mock.restore(); + }); + + test("deduplicates workspace snapshot loading for duplicate subscribers", async () => { + const { client, calls } = createClient({ + definitions: [definition("project-flow", "project"), definition("scratch-flow", "scratch")], + runs: [run({ id: "wfr_running", status: "running" })], + }); + const store = new WorkflowStore(); + store.setClient(client); + + const unsubscribeA = store.subscribeWorkspace("workspace-1", () => undefined); + const unsubscribeB = store.subscribeWorkspace("workspace-1", () => undefined); + await waitForStoreSnapshot( + () => store.getWorkspaceSnapshot("workspace-1").definitionGroups.scratch.length === 1 + ); + + expect(calls.listDefinitions).toHaveBeenCalledTimes(1); + expect(calls.listRuns).toHaveBeenCalledTimes(1); + expect(store.getWorkspaceSnapshot("workspace-1").definitionGroups.scratch).toHaveLength(1); + + unsubscribeA(); + unsubscribeB(); + store.dispose(); + }); + + test("polls active run details while subscribed and stops after the last subscriber leaves", async () => { + const { client, calls } = createClient({ + definitions: [], + runs: [run({ id: "wfr_running", status: "running" })], + runDetails: [run({ id: "wfr_running", status: "completed" })], + }); + const store = new WorkflowStore(); + store.setClient(client); + + const unsubscribe = store.subscribeWorkspace("workspace-1", () => undefined); + await waitForStoreSnapshot(() => store.getWorkspaceSnapshot("workspace-1").runs.length === 1); + await Bun.sleep(WORKFLOW_RUN_POLL_INTERVAL_MS + 20); + await waitForStoreSnapshot( + () => store.getWorkspaceSnapshot("workspace-1").runs[0]?.status === "completed" + ); + + expect(calls.getRun).toHaveBeenCalledTimes(1); + expect(store.getWorkspaceSnapshot("workspace-1").runs[0]?.status).toBe("completed"); + + unsubscribe(); + await Bun.sleep(WORKFLOW_RUN_POLL_INTERVAL_MS + 20); + + expect(calls.getRun).toHaveBeenCalledTimes(1); + store.dispose(); + }); + + test("keeps summary snapshots stable for event-only active run updates", async () => { + const running = run({ id: "wfr_running", status: "running" }); + const { client } = createClient({ + definitions: [], + runs: [running], + runDetails: [ + run({ + id: "wfr_running", + status: "running", + events: [{ type: "log", at: "2026-01-01T00:00:01.000Z", sequence: 1, message: "tick" }], + updatedAt: "2026-01-01T00:00:01.000Z", + }), + ], + }); + const store = new WorkflowStore(); + store.setClient(client); + + const unsubscribe = store.subscribeWorkspace("workspace-1", () => undefined); + await waitForStoreSnapshot(() => store.getWorkspaceSnapshot("workspace-1").runs.length === 1); + const initialSummary = store.getWorkspaceSummary("workspace-1"); + + await Bun.sleep(WORKFLOW_RUN_POLL_INTERVAL_MS + 20); + await waitForStoreSnapshot( + () => store.getWorkspaceSnapshot("workspace-1").runs[0]?.events.length === 1 + ); + + expect(store.getWorkspaceSummary("workspace-1")).toBe(initialSummary); + + unsubscribe(); + store.dispose(); + }); +}); diff --git a/src/browser/features/Workflows/WorkflowStore.ts b/src/browser/features/Workflows/WorkflowStore.ts new file mode 100644 index 0000000000..5142b36435 --- /dev/null +++ b/src/browser/features/Workflows/WorkflowStore.ts @@ -0,0 +1,333 @@ +import { useContext, useEffect, useSyncExternalStore } from "react"; + +import { APIContext, type APIClient } from "@/browser/contexts/API"; +import assert from "@/common/utils/assert"; +import type { WorkflowDefinitionDescriptor, WorkflowRunRecord } from "@/common/types/workflow"; + +import { + compareWorkflowRunsForAttention, + groupWorkflowDefinitionsByScope, + summarizeWorkflowRuns, + type WorkflowRunsSummary, +} from "./workflowStatusPresentation"; + +export const WORKFLOW_RUN_POLL_INTERVAL_MS = 2000; + +export interface WorkflowWorkspaceSnapshot { + definitions: WorkflowDefinitionDescriptor[]; + definitionGroups: ReturnType; + runs: WorkflowRunRecord[]; + currentRuns: WorkflowRunRecord[]; + historyRuns: WorkflowRunRecord[]; + summary: WorkflowRunsSummary; + isLoading: boolean; + error: string | null; +} + +const EMPTY_DEFINITION_GROUPS = groupWorkflowDefinitionsByScope([]); +const EMPTY_SUMMARY = summarizeWorkflowRuns([]); +const EMPTY_SNAPSHOT: WorkflowWorkspaceSnapshot = { + definitions: [], + definitionGroups: EMPTY_DEFINITION_GROUPS, + runs: [], + currentRuns: [], + historyRuns: [], + summary: EMPTY_SUMMARY, + isLoading: false, + error: null, +}; + +type Listener = () => void; + +interface WorkspaceState { + definitions: WorkflowDefinitionDescriptor[]; + runs: WorkflowRunRecord[]; + snapshot: WorkflowWorkspaceSnapshot; + isLoading: boolean; + error: string | null; +} + +export class WorkflowStore { + private client: APIClient | null = null; + private readonly listenersByWorkspace = new Map>(); + private readonly states = new Map(); + private readonly inFlightSnapshots = new Set(); + private readonly runTimers = new Map>(); + private disposed = false; + + setClient(client: APIClient | null): void { + this.client = client; + if (client == null || this.disposed) return; + + for (const workspaceId of this.listenersByWorkspace.keys()) { + this.refreshWorkspace(workspaceId); + } + } + + subscribeWorkspace = (workspaceId: string, listener: Listener): (() => void) => { + assert(workspaceId.length > 0, "WorkflowStore subscriptions require a workspace id"); + let listeners = this.listenersByWorkspace.get(workspaceId); + if (listeners == null) { + listeners = new Set(); + this.listenersByWorkspace.set(workspaceId, listeners); + } + listeners.add(listener); + + if (!this.states.has(workspaceId) && !this.inFlightSnapshots.has(workspaceId)) { + queueMicrotask(() => this.refreshWorkspace(workspaceId)); + } + + return () => { + const currentListeners = this.listenersByWorkspace.get(workspaceId); + currentListeners?.delete(listener); + if (currentListeners?.size === 0) { + this.listenersByWorkspace.delete(workspaceId); + this.stopWorkspaceRunPolling(workspaceId); + } + }; + }; + + getWorkspaceSnapshot(workspaceId: string | undefined): WorkflowWorkspaceSnapshot { + if (!workspaceId) return EMPTY_SNAPSHOT; + return this.states.get(workspaceId)?.snapshot ?? EMPTY_SNAPSHOT; + } + + getWorkspaceSummary(workspaceId: string | undefined): WorkflowRunsSummary { + return this.getWorkspaceSnapshot(workspaceId).summary; + } + + invalidateWorkspace(workspaceId: string): void { + this.refreshWorkspace(workspaceId); + } + + dispose(): void { + this.disposed = true; + for (const timer of this.runTimers.values()) clearTimeout(timer); + this.runTimers.clear(); + this.listenersByWorkspace.clear(); + this.states.clear(); + this.inFlightSnapshots.clear(); + } + + private refreshWorkspace(workspaceId: string): void { + if (this.client == null || this.disposed || !this.listenersByWorkspace.has(workspaceId)) return; + if (this.inFlightSnapshots.has(workspaceId)) return; + + this.inFlightSnapshots.add(workspaceId); + this.updateWorkspaceLoading(workspaceId, true, null); + void this.loadWorkspaceSnapshot(workspaceId); + } + + private async loadWorkspaceSnapshot(workspaceId: string): Promise { + assert(this.client != null, "WorkflowStore cannot load workflows without an API client"); + const client = this.client; + try { + const [definitions, runs] = await Promise.all([ + Promise.resolve() + .then(() => client.workflows.listDefinitions({ workspaceId })) + .catch(() => []), + Promise.resolve() + .then(() => client.workflows.listRuns({ workspaceId })) + .catch(() => []), + ]); + if (this.disposed) return; + this.setWorkspaceData(workspaceId, definitions, runs, false, null); + } catch (error) { + if (this.disposed) return; + this.updateWorkspaceLoading( + workspaceId, + false, + error instanceof Error ? error.message : "Failed to load workflows" + ); + } finally { + this.inFlightSnapshots.delete(workspaceId); + } + } + + private updateWorkspaceLoading( + workspaceId: string, + isLoading: boolean, + error: string | null + ): void { + const current = this.states.get(workspaceId); + this.setWorkspaceData( + workspaceId, + current?.definitions ?? [], + current?.runs ?? [], + isLoading, + error + ); + } + + private setWorkspaceData( + workspaceId: string, + definitions: WorkflowDefinitionDescriptor[], + runs: WorkflowRunRecord[], + isLoading: boolean, + error: string | null + ): void { + const previousSummary = this.states.get(workspaceId)?.snapshot.summary; + const orderedRuns = [...runs].sort(compareWorkflowRunsForAttention); + const activeOrProblemRuns = orderedRuns.filter((run) => { + const singleSummary = summarizeWorkflowRuns([run]); + return singleSummary.activeCount > 0 || singleSummary.problemCount > 0; + }); + const historyRuns = orderedRuns.filter((run) => !activeOrProblemRuns.includes(run)); + const nextSummary = summarizeWorkflowRuns(orderedRuns); + const summary = areWorkflowSummariesEqual(previousSummary, nextSummary) + ? previousSummary + : nextSummary; + const snapshot: WorkflowWorkspaceSnapshot = { + definitions, + definitionGroups: groupWorkflowDefinitionsByScope(definitions), + runs: orderedRuns, + currentRuns: activeOrProblemRuns, + historyRuns, + summary, + isLoading, + error, + }; + this.states.set(workspaceId, { definitions, runs: orderedRuns, snapshot, isLoading, error }); + this.syncRunPolling(workspaceId, orderedRuns); + this.emit(workspaceId); + } + + private syncRunPolling(workspaceId: string, runs: readonly WorkflowRunRecord[]): void { + if (!this.listenersByWorkspace.has(workspaceId)) return; + + const activeRunIds = new Set( + runs.filter((run) => summarizeWorkflowRuns([run]).activeCount > 0).map((run) => run.id) + ); + for (const runId of activeRunIds) { + const key = getRunKey(workspaceId, runId); + if (!this.runTimers.has(key)) { + this.scheduleRunPoll(workspaceId, runId); + } + } + for (const key of Array.from(this.runTimers.keys())) { + const { workspaceId: keyWorkspaceId, runId } = parseRunKey(key); + if (keyWorkspaceId === workspaceId && !activeRunIds.has(runId)) { + this.clearRunTimer(key); + } + } + } + + private scheduleRunPoll(workspaceId: string, runId: string): void { + assert(workspaceId.length > 0 && runId.length > 0, "Workflow run polling requires ids"); + const key = getRunKey(workspaceId, runId); + if (this.runTimers.has(key)) return; + const timer = setTimeout(() => { + this.runTimers.delete(key); + void this.pollRun(workspaceId, runId); + }, WORKFLOW_RUN_POLL_INTERVAL_MS); + if (typeof timer === "object" && "unref" in timer && typeof timer.unref === "function") { + timer.unref(); + } + this.runTimers.set(key, timer); + } + + private async pollRun(workspaceId: string, runId: string): Promise { + if (this.client == null || this.disposed || !this.listenersByWorkspace.has(workspaceId)) return; + const run = await this.client.workflows.getRun({ workspaceId, runId }); + if (this.disposed || !this.listenersByWorkspace.has(workspaceId) || run == null) return; + + const state = this.states.get(workspaceId); + const currentRuns = state?.runs ?? []; + const nextRuns = currentRuns.some((candidate) => candidate.id === run.id) + ? currentRuns.map((candidate) => (candidate.id === run.id ? run : candidate)) + : [run, ...currentRuns]; + this.setWorkspaceData( + workspaceId, + state?.definitions ?? [], + nextRuns, + state?.isLoading ?? false, + null + ); + } + + private stopWorkspaceRunPolling(workspaceId: string): void { + for (const key of Array.from(this.runTimers.keys())) { + if (parseRunKey(key).workspaceId === workspaceId) { + this.clearRunTimer(key); + } + } + } + + private clearRunTimer(key: string): void { + const timer = this.runTimers.get(key); + if (timer != null) clearTimeout(timer); + this.runTimers.delete(key); + } + + private emit(workspaceId: string): void { + const listeners = this.listenersByWorkspace.get(workspaceId); + if (listeners == null) return; + for (const listener of listeners) listener(); + } +} + +function areWorkflowSummariesEqual( + left: WorkflowRunsSummary | undefined, + right: WorkflowRunsSummary +): left is WorkflowRunsSummary { + return ( + left != null && + left.activeCount === right.activeCount && + left.problemCount === right.problemCount && + left.highestSeverity === right.highestSeverity + ); +} + +function getRunKey(workspaceId: string, runId: string): string { + return JSON.stringify([workspaceId, runId]); +} + +function parseRunKey(key: string): { workspaceId: string; runId: string } { + const value: unknown = JSON.parse(key); + assert(Array.isArray(value) && value.length === 2, "Invalid workflow run polling key"); + const workspaceId: unknown = value[0]; + const runId: unknown = value[1]; + assert(typeof workspaceId === "string" && typeof runId === "string", "Invalid workflow run ids"); + return { workspaceId, runId }; +} + +let workflowStoreInstance: WorkflowStore | null = null; + +export function getWorkflowStoreInstance(): WorkflowStore { + workflowStoreInstance ??= new WorkflowStore(); + return workflowStoreInstance; +} + +export function useWorkflowWorkspaceSummary(workspaceId: string | undefined): WorkflowRunsSummary { + const apiState = useContext(APIContext); + const api = apiState?.api ?? null; + const store = getWorkflowStoreInstance(); + + useEffect(() => { + store.setClient(api); + }, [api, store]); + + return useSyncExternalStore( + (listener) => (workspaceId ? store.subscribeWorkspace(workspaceId, listener) : () => undefined), + () => store.getWorkspaceSummary(workspaceId), + () => EMPTY_SUMMARY + ); +} + +export function useWorkflowWorkspaceSnapshot( + workspaceId: string | undefined +): WorkflowWorkspaceSnapshot { + const apiState = useContext(APIContext); + const api = apiState?.api ?? null; + const store = getWorkflowStoreInstance(); + + useEffect(() => { + store.setClient(api); + }, [api, store]); + + return useSyncExternalStore( + (listener) => (workspaceId ? store.subscribeWorkspace(workspaceId, listener) : () => undefined), + () => store.getWorkspaceSnapshot(workspaceId), + () => EMPTY_SNAPSHOT + ); +} diff --git a/src/browser/features/Workflows/WorkflowsTab.test.tsx b/src/browser/features/Workflows/WorkflowsTab.test.tsx new file mode 100644 index 0000000000..32a65634e9 --- /dev/null +++ b/src/browser/features/Workflows/WorkflowsTab.test.tsx @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { installDom } from "../../../../tests/ui/dom"; + +import type { WorkflowDefinitionDescriptor, WorkflowRunRecord } from "@/common/types/workflow"; + +import { WorkflowsTabView } from "./WorkflowsTab"; +import { + groupWorkflowDefinitionsByScope, + summarizeWorkflowRuns, +} from "./workflowStatusPresentation"; + +function definition( + name: string, + scope: WorkflowDefinitionDescriptor["scope"] +): WorkflowDefinitionDescriptor { + return { name, scope, description: `${name} workflow`, executable: true }; +} + +function run(overrides: Partial): WorkflowRunRecord { + return { + id: "wfr_test", + workspaceId: "workspace-1", + definition: definition("demo", "project"), + definitionSource: "/repo/.mux/workflows/demo.js", + definitionHash: "hash", + args: {}, + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + events: [], + steps: [], + ...overrides, + }; +} + +function snapshot(input: { + definitions?: WorkflowDefinitionDescriptor[]; + currentRuns?: WorkflowRunRecord[]; + historyRuns?: WorkflowRunRecord[]; +}) { + const definitions = input.definitions ?? []; + const runs = [...(input.currentRuns ?? []), ...(input.historyRuns ?? [])]; + return { + definitions, + definitionGroups: groupWorkflowDefinitionsByScope(definitions), + runs, + currentRuns: input.currentRuns ?? [], + historyRuns: input.historyRuns ?? [], + summary: summarizeWorkflowRuns(runs), + isLoading: false, + error: null, + }; +} + +describe("WorkflowsTabView", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("renders empty states without an API-backed snapshot", () => { + const view = render(); + + expect(view.getByText("No active workflows")).toBeTruthy(); + expect(view.getByText("No workflow definitions found")).toBeTruthy(); + expect(view.getByText("No workflow history yet")).toBeTruthy(); + }); + + test("groups definitions by source scope and highlights runs needing attention first", () => { + const view = render( + + ); + + expect(view.getByText(/2 current/)).toBeTruthy(); + expect(view.getByText(/1 needs attention/)).toBeTruthy(); + for (const heading of ["Project", "Global", "Built-in", "Scratch"]) { + expect(view.getByText(heading)).toBeTruthy(); + } + expect(view.getAllByText("project-flow").length).toBeGreaterThan(0); + expect(view.getAllByText("global-flow").length).toBeGreaterThan(0); + expect(view.getAllByText("built-in-flow").length).toBeGreaterThan(0); + expect(view.getAllByText("scratch-flow").length).toBeGreaterThan(0); + expect(view.getByText("Recent history")).toBeTruthy(); + }); + + test("offers foreground and background run actions", () => { + const started: Array<{ name: string; runInBackground: boolean }> = []; + const project = definition("project-flow", "project"); + const view = render( + { + started.push({ + name: definition.name, + runInBackground: options?.runInBackground === true, + }); + }} + /> + ); + + fireEvent.click(view.getByRole("button", { name: "Run project-flow" })); + fireEvent.click(view.getByRole("button", { name: "Run project-flow in background" })); + + expect(started).toEqual([ + { name: "project-flow", runInBackground: false }, + { name: "project-flow", runInBackground: true }, + ]); + }); + + test("offers supported current-run actions", () => { + const actions: Array<{ id: string; action: string }> = []; + const running = run({ + id: "wfr_running", + status: "running", + definition: definition("running-flow", "project"), + }); + const backgrounded = run({ + id: "wfr_backgrounded", + status: "backgrounded", + definition: definition("backgrounded-flow", "project"), + }); + const interrupted = run({ + id: "wfr_interrupted", + status: "interrupted", + definition: definition("interrupted-flow", "project"), + }); + const failed = run({ + id: "wfr_failed", + status: "failed", + definition: definition("failed-flow", "project"), + }); + const completed = run({ + id: "wfr_completed", + status: "completed", + definition: definition("completed-flow", "project"), + }); + const view = render( + { + actions.push({ id: run.id, action }); + }} + /> + ); + + fireEvent.click(view.getByRole("button", { name: "Interrupt running-flow" })); + fireEvent.click(view.getByRole("button", { name: "Interrupt backgrounded-flow" })); + fireEvent.click(view.getByRole("button", { name: "Resume interrupted-flow" })); + fireEvent.click(view.getByRole("button", { name: "Retry failed-flow" })); + + expect(view.queryByRole("button", { name: "Retry completed-flow" })).toBeNull(); + expect(actions).toEqual([ + { id: "wfr_running", action: "interrupt" }, + { id: "wfr_backgrounded", action: "interrupt" }, + { id: "wfr_interrupted", action: "resume" }, + { id: "wfr_failed", action: "retryFromCheckpoint" }, + ]); + }); + + test("offers promotion actions for scratch definitions", () => { + const promoted: Array<{ name: string; location: "project" | "global" }> = []; + const scratch = definition("scratch-flow", "scratch"); + const view = render( + { + promoted.push({ name: definition.name, location }); + }} + /> + ); + + fireEvent.click(view.getByRole("button", { name: "Save scratch-flow to project workflows" })); + fireEvent.click(view.getByRole("button", { name: "Save scratch-flow to global workflows" })); + + expect(promoted).toEqual([ + { name: "scratch-flow", location: "project" }, + { name: "scratch-flow", location: "global" }, + ]); + }); +}); diff --git a/src/browser/features/Workflows/WorkflowsTab.tsx b/src/browser/features/Workflows/WorkflowsTab.tsx new file mode 100644 index 0000000000..f194b5433f --- /dev/null +++ b/src/browser/features/Workflows/WorkflowsTab.tsx @@ -0,0 +1,411 @@ +import React, { useContext, useState } from "react"; +import { Play, RefreshCw } from "lucide-react"; + +import { APIContext } from "@/browser/contexts/API"; +import { Button } from "@/browser/components/Button/Button"; +import { cn } from "@/common/lib/utils"; +import type { + WorkflowDefinitionDescriptor, + WorkflowDefinitionScope, + WorkflowRunRecord, +} from "@/common/types/workflow"; + +import { + getWorkflowStoreInstance, + type WorkflowWorkspaceSnapshot, + useWorkflowWorkspaceSnapshot, +} from "./WorkflowStore"; +import { + getLatestWorkflowRunSummary, + getWorkflowStatusPresentation, + type WorkflowStatusSeverity, +} from "./workflowStatusPresentation"; + +interface WorkflowsTabProps { + workspaceId: string; +} + +interface RunWorkflowOptions { + runInBackground: boolean; +} + +type WorkflowRunAction = "interrupt" | "resume" | "retryFromCheckpoint"; + +interface WorkflowsTabViewProps { + snapshot: WorkflowWorkspaceSnapshot; + onRunDefinition?: ( + definition: WorkflowDefinitionDescriptor, + options?: RunWorkflowOptions + ) => Promise | void; + onRunAction?: (run: WorkflowRunRecord, action: WorkflowRunAction) => Promise | void; + onPromoteScratchDefinition?: ( + definition: WorkflowDefinitionDescriptor, + location: "project" | "global" + ) => Promise | void; + onRefresh?: () => void; +} + +const SCOPE_LABELS: Record = { + project: "Project", + global: "Global", + "built-in": "Built-in", + scratch: "Scratch", +}; + +const SCOPE_ORDER: WorkflowDefinitionScope[] = ["project", "global", "built-in", "scratch"]; + +const SEVERITY_CLASS: Record = { + error: "text-error border-error/40 bg-error/10", + warning: "text-warning border-warning/40 bg-warning/10", + active: "text-success border-success/40 bg-success/10", + "active-background": "text-muted border-border bg-secondary", + pending: "text-muted border-border bg-secondary", + terminal: "text-muted border-border bg-secondary", + unknown: "text-muted border-border bg-secondary", +}; + +export function WorkflowsTab(props: WorkflowsTabProps) { + const apiState = useContext(APIContext); + const snapshot = useWorkflowWorkspaceSnapshot(props.workspaceId); + const [actionError, setActionError] = useState(null); + + const handleRunDefinition = async ( + definition: WorkflowDefinitionDescriptor, + options?: RunWorkflowOptions + ) => { + if (!apiState?.api) return; + setActionError(null); + try { + await apiState.api.workflows.start({ + workspaceId: props.workspaceId, + name: definition.name, + runInBackground: options?.runInBackground === true, + }); + getWorkflowStoreInstance().invalidateWorkspace(props.workspaceId); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Failed to start workflow"); + } + }; + + const handleRunAction = async (run: WorkflowRunRecord, action: WorkflowRunAction) => { + if (!apiState?.api) return; + setActionError(null); + try { + if (action === "interrupt") { + await apiState.api.workflows.interrupt({ workspaceId: props.workspaceId, runId: run.id }); + } else if (action === "resume") { + await apiState.api.workflows.resume({ workspaceId: props.workspaceId, runId: run.id }); + } else { + await apiState.api.workflows.retryFromCheckpoint({ + workspaceId: props.workspaceId, + runId: run.id, + }); + } + getWorkflowStoreInstance().invalidateWorkspace(props.workspaceId); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Failed to update workflow"); + } + }; + + const handlePromoteScratchDefinition = async ( + definition: WorkflowDefinitionDescriptor, + location: "project" | "global" + ) => { + if (!apiState?.api) return; + setActionError(null); + try { + await apiState.api.workflows.promoteScratchDefinition({ + workspaceId: props.workspaceId, + name: definition.name, + description: definition.description, + location, + overwrite: false, + }); + getWorkflowStoreInstance().invalidateWorkspace(props.workspaceId); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Failed to save workflow"); + } + }; + + return ( +
+ +
+ ); +} + +export function WorkflowsTabView(props: WorkflowsTabViewProps) { + const { snapshot } = props; + return ( +
+
+
+
+

Workflows

+

+ {snapshot.summary.activeCount} active · {snapshot.summary.problemCount} needing + attention +

+
+ {props.onRefresh && ( + + )} +
+ {snapshot.error &&

{snapshot.error}

} +
+ +
+
+

Current runs

+ {snapshot.currentRuns.length > 0 && ( + + {snapshot.currentRuns.length} current + {snapshot.summary.problemCount > 0 + ? ` · ${snapshot.summary.problemCount} needs attention` + : ""} + + )} +
+ {snapshot.currentRuns.length === 0 ? ( + No active workflows + ) : ( + snapshot.currentRuns.map((run) => ( + + )) + )} +
+ +
+

+ Available definitions +

+ {snapshot.definitions.length === 0 ? ( + No workflow definitions found + ) : ( + SCOPE_ORDER.map((scope) => { + const definitions = snapshot.definitionGroups[scope]; + if (definitions.length === 0) return null; + return ( +
+ + {SCOPE_LABELS[scope]} + +
+ {definitions.map((definition) => ( + + ))} +
+
+ ); + }) + )} +
+ +
+

Recent history

+ {snapshot.historyRuns.length === 0 ? ( + No workflow history yet + ) : ( +
+ + {snapshot.historyRuns.length} completed or inactive run + {snapshot.historyRuns.length === 1 ? "" : "s"} + +
+ {snapshot.historyRuns.slice(0, 10).map((run) => ( + + ))} +
+
+ )} +
+
+ ); +} + +function WorkflowRunCard(props: { + run: WorkflowRunRecord; + compact?: boolean; + onAction?: (run: WorkflowRunRecord, action: WorkflowRunAction) => Promise | void; +}) { + const presentation = getWorkflowStatusPresentation(props.run.status); + const action = getWorkflowRunAction(props.run.status); + return ( +
+
+
+
{props.run.definition.name}
+ {!props.compact && ( +
+ {getLatestWorkflowRunSummary(props.run)} +
+ )} +
+
+ + {!props.compact && props.onAction && action && ( + + )} +
+
+
{props.run.id}
+
+ ); +} + +function WorkflowDefinitionRow(props: { + definition: WorkflowDefinitionDescriptor; + onRun?: ( + definition: WorkflowDefinitionDescriptor, + options?: RunWorkflowOptions + ) => Promise | void; + onPromoteScratchDefinition?: ( + definition: WorkflowDefinitionDescriptor, + location: "project" | "global" + ) => Promise | void; +}) { + return ( +
+
+
+
{props.definition.name}
+

{props.definition.description}

+ {props.definition.sourcePath && ( +

{props.definition.sourcePath}

+ )} + {!props.definition.executable && props.definition.blockedReason && ( +

{props.definition.blockedReason}

+ )} +
+ {props.definition.executable && ( +
+ {props.onRun && ( + <> + + + + )} + {props.definition.scope === "scratch" && props.onPromoteScratchDefinition && ( + <> + + + + )} +
+ )} +
+
+ ); +} + +function getWorkflowRunAction( + status: WorkflowRunRecord["status"] +): { action: WorkflowRunAction; label: string } | null { + if (status === "pending" || status === "running" || status === "backgrounded") { + return { action: "interrupt", label: "Interrupt" }; + } + if (status === "interrupted") { + return { action: "resume", label: "Resume" }; + } + if (status === "failed") { + return { action: "retryFromCheckpoint", label: "Retry" }; + } + return null; +} + +function StatusBadge(props: { label: string; severity: WorkflowStatusSeverity }) { + return ( + + {props.label} + + ); +} + +function EmptyState(props: { children: React.ReactNode }) { + return ( +
+ {props.children} +
+ ); +} diff --git a/src/browser/features/Workflows/workflowStatusPresentation.test.ts b/src/browser/features/Workflows/workflowStatusPresentation.test.ts new file mode 100644 index 0000000000..a5b371df9a --- /dev/null +++ b/src/browser/features/Workflows/workflowStatusPresentation.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; + +import type { WorkflowRunRecord } from "@/common/types/workflow"; +import { + compareWorkflowRunsForAttention, + getLatestWorkflowRunSummary, + getWorkflowStatusPresentation, + summarizeWorkflowRuns, +} from "./workflowStatusPresentation"; + +function run(overrides: Partial): WorkflowRunRecord { + return { + id: "wfr_test", + workspaceId: "workspace-1", + definition: { + name: "demo", + description: "Demo workflow", + scope: "project", + executable: true, + sourcePath: "/repo/.mux/workflows/demo.js", + }, + definitionSource: "/repo/.mux/workflows/demo.js", + definitionHash: "hash", + args: {}, + status: "pending", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + events: [], + steps: [], + ...overrides, + }; +} + +describe("workflow status presentation", () => { + test("aggregates active and problem runs with problem severity winning over activity", () => { + const summary = summarizeWorkflowRuns([ + run({ id: "wfr_running", status: "running" }), + run({ id: "wfr_failed", status: "failed" }), + run({ id: "wfr_completed", status: "completed" }), + ]); + + expect(summary.activeCount).toBe(1); + expect(summary.problemCount).toBe(1); + expect(summary.highestSeverity).toBe("error"); + }); + + test("sorts failed and interrupted runs before live runs, then by most recent update", () => { + const sorted = [ + run({ id: "wfr_old_running", status: "running", updatedAt: "2026-01-01T00:00:00.000Z" }), + run({ id: "wfr_new_running", status: "running", updatedAt: "2026-01-03T00:00:00.000Z" }), + run({ id: "wfr_interrupted", status: "interrupted", updatedAt: "2026-01-02T00:00:00.000Z" }), + run({ id: "wfr_failed", status: "failed", updatedAt: "2026-01-01T00:00:00.000Z" }), + ].sort(compareWorkflowRunsForAttention); + + expect(sorted.map((item) => item.id)).toEqual([ + "wfr_failed", + "wfr_interrupted", + "wfr_new_running", + "wfr_old_running", + ]); + }); + + test("summarizes the latest meaningful event instead of status churn", () => { + const summary = getLatestWorkflowRunSummary( + run({ + events: [ + { sequence: 1, type: "status", at: "2026-01-01T00:00:00.000Z", status: "running" }, + { sequence: 2, type: "phase", at: "2026-01-01T00:00:01.000Z", name: "Collect inputs" }, + { sequence: 3, type: "log", at: "2026-01-01T00:00:02.000Z", message: "Fetched 3 items" }, + ], + }) + ); + + expect(summary).toBe("Fetched 3 items"); + }); + + test("falls back safely for unrecognized persisted statuses", () => { + const presentation = getWorkflowStatusPresentation("paused-by-old-build"); + + expect(presentation.severity).toBe("unknown"); + expect(presentation.isActive).toBe(false); + expect(presentation.needsAttention).toBe(false); + }); +}); diff --git a/src/browser/features/Workflows/workflowStatusPresentation.ts b/src/browser/features/Workflows/workflowStatusPresentation.ts new file mode 100644 index 0000000000..a42fe1e318 --- /dev/null +++ b/src/browser/features/Workflows/workflowStatusPresentation.ts @@ -0,0 +1,155 @@ +import type { + WorkflowDefinitionDescriptor, + WorkflowDefinitionScope, + WorkflowRunEvent, + WorkflowRunRecord, + WorkflowRunStatus, +} from "@/common/types/workflow"; +import assert from "@/common/utils/assert"; + +export type WorkflowStatusSeverity = + | "error" + | "warning" + | "active" + | "active-background" + | "pending" + | "terminal" + | "unknown"; + +export interface WorkflowStatusPresentation { + label: string; + severity: WorkflowStatusSeverity; + isActive: boolean; + needsAttention: boolean; +} + +export interface WorkflowRunsSummary { + activeCount: number; + problemCount: number; + highestSeverity: WorkflowStatusSeverity | null; +} + +const WORKFLOW_STATUS_PRESENTATION: Record = { + failed: { label: "Failed", severity: "error", isActive: false, needsAttention: true }, + interrupted: { + label: "Interrupted", + severity: "warning", + isActive: false, + needsAttention: true, + }, + running: { label: "Running", severity: "active", isActive: true, needsAttention: false }, + backgrounded: { + label: "Backgrounded", + severity: "active-background", + isActive: true, + needsAttention: false, + }, + pending: { label: "Pending", severity: "pending", isActive: true, needsAttention: false }, + completed: { label: "Completed", severity: "terminal", isActive: false, needsAttention: false }, +}; + +const KNOWN_WORKFLOW_STATUSES = new Set( + Object.keys(WORKFLOW_STATUS_PRESENTATION) as WorkflowRunStatus[] +); + +const SEVERITY_RANK: Record = { + error: 6, + warning: 5, + active: 4, + "active-background": 3, + pending: 2, + terminal: 1, + unknown: 0, +}; + +export function getWorkflowStatusPresentation(status: string): WorkflowStatusPresentation { + if (KNOWN_WORKFLOW_STATUSES.has(status as WorkflowRunStatus)) { + return WORKFLOW_STATUS_PRESENTATION[status as WorkflowRunStatus]; + } + + // Persisted runs may have been written by a newer or older build. Keep the UI usable, + // while assertions/tests make newly-added statuses impossible to forget during development. + return { + label: status || "Unknown", + severity: "unknown", + isActive: false, + needsAttention: false, + }; +} + +export function summarizeWorkflowRuns(runs: readonly WorkflowRunRecord[]): WorkflowRunsSummary { + let activeCount = 0; + let problemCount = 0; + let highestSeverity: WorkflowStatusSeverity | null = null; + + for (const run of runs) { + assert(run.id.length > 0, "Workflow run summaries require persisted run ids"); + const presentation = getWorkflowStatusPresentation(run.status); + if (presentation.isActive) activeCount++; + if (presentation.needsAttention) problemCount++; + if ( + highestSeverity == null || + SEVERITY_RANK[presentation.severity] > SEVERITY_RANK[highestSeverity] + ) { + highestSeverity = presentation.severity; + } + } + + return { activeCount, problemCount, highestSeverity }; +} + +export function compareWorkflowRunsForAttention( + a: WorkflowRunRecord, + b: WorkflowRunRecord +): number { + const aSeverity = getWorkflowStatusPresentation(a.status).severity; + const bSeverity = getWorkflowStatusPresentation(b.status).severity; + const severityDelta = SEVERITY_RANK[bSeverity] - SEVERITY_RANK[aSeverity]; + if (severityDelta !== 0) return severityDelta; + return Date.parse(b.updatedAt) - Date.parse(a.updatedAt); +} + +export function getLatestWorkflowRunSummary(run: WorkflowRunRecord): string { + for (let index = run.events.length - 1; index >= 0; index--) { + const event = run.events[index]; + const summary = summarizeEvent(event); + if (summary != null) return summary; + } + return getWorkflowStatusPresentation(run.status).label; +} + +function summarizeEvent(event: WorkflowRunEvent): string | null { + switch (event.type) { + case "status": + case "result": + return null; + case "phase": + return event.name; + case "log": + case "error": + return event.message; + case "task": + return `Task ${event.status}`; + case "patch": + return `Patch ${event.status}`; + case "action": + return `${event.name} ${event.status}`; + case "validation": + return event.message ?? (event.success ? "Validation passed" : "Validation failed"); + default: { + const exhaustive: never = event; + return exhaustive; + } + } +} + +export function groupWorkflowDefinitionsByScope( + definitions: readonly WorkflowDefinitionDescriptor[] +): Record { + return { + project: definitions.filter((definition) => definition.scope === "project"), + global: definitions.filter((definition) => definition.scope === "global"), + "built-in": definitions.filter((definition) => definition.scope === "built-in"), + scratch: definitions.filter((definition) => definition.scope === "scratch"), + }; +} diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index df2b5ffbac..28c7251095 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -123,6 +123,12 @@ export const CUSTOM_EVENTS = { */ OPEN_GOAL_TAB: "mux:openGoalTab", + /** + * Event to open the right-sidebar Workflows tab. + * Detail: { workspaceId: string } + */ + OPEN_WORKFLOWS_TAB: "mux:openWorkflowsTab", + /** * Event to show a toast when a child task pushes the parent's goal over budget. * Detail: { workspaceId: string, message: string } @@ -197,6 +203,9 @@ export interface CustomEventPayloads { workspaceId: string; openCompleteInput?: boolean; }; + [CUSTOM_EVENTS.OPEN_WORKFLOWS_TAB]: { + workspaceId: string; + }; [CUSTOM_EVENTS.GOAL_CHILD_BUDGET_TOAST]: { workspaceId: string; message: string; diff --git a/src/node/services/workflows/WorkflowDefinitionStore.test.ts b/src/node/services/workflows/WorkflowDefinitionStore.test.ts index 01573ee346..bb6e65f296 100644 --- a/src/node/services/workflows/WorkflowDefinitionStore.test.ts +++ b/src/node/services/workflows/WorkflowDefinitionStore.test.ts @@ -107,6 +107,31 @@ describe("WorkflowDefinitionStore", () => { ); }); + test("project definitions win over scratch drafts with the same name", async () => { + using tmp = new DisposableTempDir("workflow-definitions-project-over-scratch"); + const projectRoot = path.join(tmp.path, "project", ".mux", "workflows"); + const scratchRoot = path.join(projectRoot, ".scratch"); + const globalRoot = path.join(tmp.path, "mux-home", "workflows"); + await writeWorkflow(projectRoot, "demo", "Project demo"); + await writeWorkflow(scratchRoot, "demo", "Scratch demo"); + + const store = new WorkflowDefinitionStore({ + projectRoot, + scratchRoot, + globalRoot, + builtIns: [], + }); + + const definitions = await store.listDefinitions({ projectTrusted: true }); + const definition = await store.readDefinition("demo", { projectTrusted: true }); + + expect(definitions.map((candidate) => [candidate.name, candidate.scope])).toEqual([ + ["demo", "project"], + ]); + expect(definition.descriptor.scope).toBe("project"); + expect(definition.source).toContain("Project demo"); + }); + test("omits project-local workflows when the project is not trusted", async () => { using tmp = new DisposableTempDir("workflow-definitions"); const projectRoot = path.join(tmp.path, "project", ".mux", "workflows"); diff --git a/src/node/services/workflows/WorkflowDefinitionStore.ts b/src/node/services/workflows/WorkflowDefinitionStore.ts index 78115afade..c0d0e273a2 100644 --- a/src/node/services/workflows/WorkflowDefinitionStore.ts +++ b/src/node/services/workflows/WorkflowDefinitionStore.ts @@ -908,11 +908,30 @@ export class WorkflowDefinitionStore { const byName = new Map(); const sources: ScannedWorkflowDefinition[][] = []; + if (options.projectTrusted) { + if (this.projectRuntime != null) { + assert( + this.projectCwd != null, + "WorkflowDefinitionStore.collectDefinitions: projectCwd missing" + ); + sources.push( + await scanRuntimeDirectory( + this.projectRuntime, + this.projectRoot, + this.projectCwd, + "project" + ) + ); + } else { + sources.push(await scanDirectory(this.projectRoot, "project")); + } + } if (this.scratchRoot != null && options.projectTrusted) { // Scratch workflows live under the workspace checkout, so treat them like project-local // code for trust gating rather than exposing repo-controlled files from untrusted projects. // Keep plain workflow discovery read-only: only create/touch scratch ignore files once - // there is an actual scratch workflow candidate for Git to hide. + // there is an actual scratch workflow candidate for Git to hide. Project definitions still + // take precedence so promoting a scratch draft to a reusable workflow is visible immediately. if (this.projectRuntime != null) { assert( this.projectCwd != null, @@ -961,24 +980,6 @@ export class WorkflowDefinitionStore { sources.push(scratchDefinitions); } } - if (options.projectTrusted) { - if (this.projectRuntime != null) { - assert( - this.projectCwd != null, - "WorkflowDefinitionStore.collectDefinitions: projectCwd missing" - ); - sources.push( - await scanRuntimeDirectory( - this.projectRuntime, - this.projectRoot, - this.projectCwd, - "project" - ) - ); - } else { - sources.push(await scanDirectory(this.projectRoot, "project")); - } - } sources.push(await scanDirectory(this.globalRoot, "global")); sources.push(readBuiltInDefinitions(this.builtIns)); From 6f67de8d9b79dc9e7053abe8023807e25cca7356 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 8 Jun 2026 17:39:06 +0000 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=A4=96=20fix:=20harden=20workflow=20v?= =?UTF-8?q?isibility=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/App.tsx | 4 + .../WorkspaceMenuBar/WorkspaceMenuBar.tsx | 3 +- .../features/RightSidebar/RightSidebar.tsx | 18 +- .../features/RightSidebar/Tabs/tabConfig.ts | 1 + .../features/Tools/WorkflowRunToolCall.tsx | 10 +- .../features/Workflows/WorkflowStore.test.ts | 186 +++++++++++++++-- .../features/Workflows/WorkflowStore.ts | 194 +++++++++++++++--- .../features/Workflows/WorkflowsTab.test.tsx | 43 +++- .../features/Workflows/WorkflowsTab.tsx | 70 ++++--- src/browser/utils/chatCommands.ts | 2 + src/browser/utils/commands/sources.test.ts | 21 ++ src/browser/utils/commands/sources.ts | 10 +- 12 files changed, 468 insertions(+), 94 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 1cca2d9c7c..9765f9da8a 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -190,6 +190,7 @@ function AppInner() { ); const [isMultiProjectWorkspaceModalOpen, setMultiProjectWorkspaceModalOpen] = useState(false); + const dynamicWorkflowsEnabled = useExperimentValue(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS); const multiProjectWorkspacesEnabled = useExperimentValue(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES); // Left sidebar is drag-resizable (mirrors RightSidebar). Width is persisted globally; @@ -717,6 +718,9 @@ function AppInner() { onSetThinkingLevel: setThinkingLevelFromPalette, getMinThinkingOverride, onStartWorkspaceCreation: openNewWorkspaceFromPalette, + enabledRightSidebarFeatureFlags: dynamicWorkflowsEnabled + ? new Set([EXPERIMENT_IDS.DYNAMIC_WORKFLOWS]) + : undefined, onStartMultiProjectWorkspaceCreation: openNewMultiProjectWorkspaceFromPalette, multiProjectWorkspacesEnabled, onArchiveMergedWorkspacesInProject: archiveMergedWorkspacesInProjectFromPalette, diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx index f2ced227b0..a33a90afdb 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx @@ -96,6 +96,7 @@ export const WorkspaceMenuBar: React.FC = ({ const { disableWorkspaceAgents } = useAgent(); const { preflightArchiveWorkspace, archiveWorkspace } = useWorkspaceActions(); const { workspaceMetadata } = useWorkspaceContext(); + const dynamicWorkflowsEnabled = useExperimentValue(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS); const workspaceHeartbeatsEnabled = useExperimentValue(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS); const linkSharingEnabled = useLinkSharingEnabled(); const openTerminalPopout = useOpenTerminal(); @@ -684,7 +685,7 @@ export const WorkspaceMenuBar: React.FC = ({ - + {dynamicWorkflowsEnabled && } = ({ // API for reading config and managing terminal sessions. const apiState = useAPI(); const api = apiState.api; + const workflowsExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS); const desktopExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.PORTABLE_DESKTOP); const browserExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.AGENT_BROWSER); // Child task workspaces can't run goal actions — backend rejects them @@ -983,6 +984,19 @@ const RightSidebarComponent: React.FC = ({ }); }, [initialActiveTab, setLayoutRaw, isChildWorkspaceForGoal]); + React.useEffect(() => { + if (workflowsExperimentEnabled) { + return; + } + + setLayoutRaw((prevRaw) => { + const prev = parseRightSidebarLayoutState(prevRaw, initialActiveTab); + return collectAllTabs(prev.root).includes("workflows") + ? removeTabEverywhere(prev, "workflows") + : prev; + }); + }, [initialActiveTab, setLayoutRaw, workflowsExperimentEnabled]); + React.useEffect(() => { if (!desktopExperimentEnabled) { setDesktopAvailable(false); @@ -1112,7 +1126,7 @@ const RightSidebarComponent: React.FC = ({ React.useEffect(() => { const handleOpenWorkflowsTab = (event: Event) => { const detail = (event as CustomEvent<{ workspaceId: string }>).detail; - if (detail?.workspaceId !== workspaceId) { + if (detail?.workspaceId !== workspaceId || !workflowsExperimentEnabled) { return; } setCollapsed(false); @@ -1122,7 +1136,7 @@ const RightSidebarComponent: React.FC = ({ window.addEventListener(CUSTOM_EVENTS.OPEN_WORKFLOWS_TAB, handleOpenWorkflowsTab); return () => window.removeEventListener(CUSTOM_EVENTS.OPEN_WORKFLOWS_TAB, handleOpenWorkflowsTab); - }, [setCollapsed, setLayout, workspaceId]); + }, [setCollapsed, setLayout, workflowsExperimentEnabled, workspaceId]); React.useEffect(() => { const handleOpenTouchReviewImmersive = (event: Event) => { diff --git a/src/browser/features/RightSidebar/Tabs/tabConfig.ts b/src/browser/features/RightSidebar/Tabs/tabConfig.ts index 50a570b0f3..4a707ab5fc 100644 --- a/src/browser/features/RightSidebar/Tabs/tabConfig.ts +++ b/src/browser/features/RightSidebar/Tabs/tabConfig.ts @@ -56,6 +56,7 @@ const TAB_CONFIG_DEF = { workflows: { name: "Workflows", contentClassName: "overflow-hidden p-0", + featureFlag: EXPERIMENT_IDS.DYNAMIC_WORKFLOWS, defaultOrder: 37, paletteKeywords: ["workflow", "workflows", "automation", "run"], }, diff --git a/src/browser/features/Tools/WorkflowRunToolCall.tsx b/src/browser/features/Tools/WorkflowRunToolCall.tsx index cb76daddaf..9c247313cd 100644 --- a/src/browser/features/Tools/WorkflowRunToolCall.tsx +++ b/src/browser/features/Tools/WorkflowRunToolCall.tsx @@ -60,7 +60,10 @@ import { formatWorkflowSavedMessage, type WorkflowPromotionTarget, } from "./WorkflowDefinitionToolCall"; -import { useWorkflowWorkspaceSnapshot } from "@/browser/features/Workflows/WorkflowStore"; +import { + getWorkflowStoreInstance, + useWorkflowWorkspaceSnapshot, +} from "@/browser/features/Workflows/WorkflowStore"; import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { MarkdownRenderer } from "../Messages/MarkdownRenderer"; @@ -116,6 +119,7 @@ async function updateWorkflowRunFromAction(input: { workspaceId: input.workspaceId, runId: input.runId, }); + getWorkflowStoreInstance().invalidateWorkspace(input.workspaceId); if (input.action === "resume" || input.action === "retryFromCheckpoint") { resumeRequestAccepted = true; input.setResumingRunId(input.runId); @@ -1088,12 +1092,13 @@ export const WorkflowRunToolCall: React.FC = ({ } assert(sourceDefinition.scope === "scratch", "Only scratch workflow runs can be saved"); + const sourceWorkspaceId = run.workspaceId; setActionError(null); setSavingPromotionTarget(location); savingPromotionTargetRef.current = location; api.workflows .promoteScratch({ - workspaceId: run.workspaceId, + workspaceId: sourceWorkspaceId, runId, name: sourceDefinition.name, description: sourceDefinition.description, @@ -1105,6 +1110,7 @@ export const WorkflowRunToolCall: React.FC = ({ descriptor.scope === location, "promoteScratch returned a descriptor for a different location" ); + getWorkflowStoreInstance().invalidateWorkspace(sourceWorkspaceId); setPromotedDefinition(descriptor); }) .catch((error: unknown) => { diff --git a/src/browser/features/Workflows/WorkflowStore.test.ts b/src/browser/features/Workflows/WorkflowStore.test.ts index ea7df20f93..bffaabff98 100644 --- a/src/browser/features/Workflows/WorkflowStore.test.ts +++ b/src/browser/features/Workflows/WorkflowStore.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import type { APIClient } from "@/browser/contexts/API"; import type { WorkflowDefinitionDescriptor, WorkflowRunRecord } from "@/common/types/workflow"; -import { WORKFLOW_RUN_POLL_INTERVAL_MS, WorkflowStore } from "./WorkflowStore"; +import { WorkflowStore } from "./WorkflowStore"; function definition( name: string, @@ -29,15 +29,33 @@ function run(overrides: Partial): WorkflowRunRecord { }; } +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + function createClient(input: { definitions?: WorkflowDefinitionDescriptor[]; runs?: WorkflowRunRecord[]; - runDetails?: WorkflowRunRecord[]; + runDetails?: Array; + listDefinitions?: () => Promise; + listRuns?: () => Promise; }) { const calls = { - listDefinitions: mock(() => Promise.resolve(input.definitions ?? [])), - listRuns: mock(() => Promise.resolve(input.runs ?? [])), - getRun: mock(() => Promise.resolve(input.runDetails?.shift() ?? input.runs?.[0] ?? null)), + listDefinitions: mock( + input.listDefinitions ?? (() => Promise.resolve(input.definitions ?? [])) + ), + listRuns: mock(input.listRuns ?? (() => Promise.resolve(input.runs ?? []))), + getRun: mock(() => { + const next = input.runDetails?.shift(); + if (next instanceof Error) return Promise.reject(next); + return Promise.resolve(next ?? input.runs?.[0] ?? null); + }), }; const client = { workflows: { @@ -49,10 +67,17 @@ function createClient(input: { return { client, calls }; } +function createFastStore(options: { workspaceRefreshIntervalMs?: number } = {}) { + return new WorkflowStore({ + runPollIntervalMs: 1, + workspaceRefreshIntervalMs: options.workspaceRefreshIntervalMs ?? 1_000, + }); +} + async function waitForStoreSnapshot(predicate: () => boolean): Promise { - for (let attempt = 0; attempt < 20; attempt++) { + for (let attempt = 0; attempt < 80; attempt++) { if (predicate()) return; - await Bun.sleep(1); + await Bun.sleep(2); } throw new Error("Timed out waiting for workflow store snapshot"); } @@ -67,7 +92,7 @@ describe("WorkflowStore", () => { definitions: [definition("project-flow", "project"), definition("scratch-flow", "scratch")], runs: [run({ id: "wfr_running", status: "running" })], }); - const store = new WorkflowStore(); + const store = createFastStore(); store.setClient(client); const unsubscribeA = store.subscribeWorkspace("workspace-1", () => undefined); @@ -91,12 +116,10 @@ describe("WorkflowStore", () => { runs: [run({ id: "wfr_running", status: "running" })], runDetails: [run({ id: "wfr_running", status: "completed" })], }); - const store = new WorkflowStore(); + const store = createFastStore(); store.setClient(client); const unsubscribe = store.subscribeWorkspace("workspace-1", () => undefined); - await waitForStoreSnapshot(() => store.getWorkspaceSnapshot("workspace-1").runs.length === 1); - await Bun.sleep(WORKFLOW_RUN_POLL_INTERVAL_MS + 20); await waitForStoreSnapshot( () => store.getWorkspaceSnapshot("workspace-1").runs[0]?.status === "completed" ); @@ -105,12 +128,37 @@ describe("WorkflowStore", () => { expect(store.getWorkspaceSnapshot("workspace-1").runs[0]?.status).toBe("completed"); unsubscribe(); - await Bun.sleep(WORKFLOW_RUN_POLL_INTERVAL_MS + 20); + await Bun.sleep(5); expect(calls.getRun).toHaveBeenCalledTimes(1); store.dispose(); }); + test("restarts active-run polling when a cached workspace is resubscribed", async () => { + const { client, calls } = createClient({ + definitions: [], + runs: [run({ id: "wfr_running", status: "running" })], + runDetails: [run({ id: "wfr_running", status: "completed" })], + }); + const store = new WorkflowStore({ runPollIntervalMs: 20, workspaceRefreshIntervalMs: 1_000 }); + store.setClient(client); + + const unsubscribeFirst = store.subscribeWorkspace("workspace-1", () => undefined); + await waitForStoreSnapshot(() => store.getWorkspaceSnapshot("workspace-1").runs.length === 1); + unsubscribeFirst(); + await Bun.sleep(5); + expect(calls.getRun).toHaveBeenCalledTimes(0); + + const unsubscribeSecond = store.subscribeWorkspace("workspace-1", () => undefined); + await waitForStoreSnapshot( + () => store.getWorkspaceSnapshot("workspace-1").runs[0]?.status === "completed" + ); + + expect(calls.getRun).toHaveBeenCalledTimes(1); + unsubscribeSecond(); + store.dispose(); + }); + test("keeps summary snapshots stable for event-only active run updates", async () => { const running = run({ id: "wfr_running", status: "running" }); const { client } = createClient({ @@ -125,14 +173,13 @@ describe("WorkflowStore", () => { }), ], }); - const store = new WorkflowStore(); + const store = createFastStore(); store.setClient(client); const unsubscribe = store.subscribeWorkspace("workspace-1", () => undefined); await waitForStoreSnapshot(() => store.getWorkspaceSnapshot("workspace-1").runs.length === 1); const initialSummary = store.getWorkspaceSummary("workspace-1"); - await Bun.sleep(WORKFLOW_RUN_POLL_INTERVAL_MS + 20); await waitForStoreSnapshot( () => store.getWorkspaceSnapshot("workspace-1").runs[0]?.events.length === 1 ); @@ -142,4 +189,115 @@ describe("WorkflowStore", () => { unsubscribe(); store.dispose(); }); + + test("discovers externally-created runs from periodic workspace refreshes", async () => { + let listRunsCallCount = 0; + const externalRun = run({ id: "wfr_external", status: "running" }); + const { client, calls } = createClient({ + definitions: [], + listRuns: () => Promise.resolve(listRunsCallCount++ === 0 ? [] : [externalRun]), + }); + const store = createFastStore({ workspaceRefreshIntervalMs: 1 }); + store.setClient(client); + + const unsubscribe = store.subscribeWorkspace("workspace-1", () => undefined); + await waitForStoreSnapshot(() => store.getWorkspaceSnapshot("workspace-1").runs.length === 1); + + expect(calls.listRuns.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(store.getWorkspaceSnapshot("workspace-1").runs[0]?.id).toBe("wfr_external"); + + unsubscribe(); + store.dispose(); + }); + + test("queues invalidation requests that arrive during an in-flight snapshot load", async () => { + const firstRuns = createDeferred(); + let listRunsCallCount = 0; + const afterActionRun = run({ id: "wfr_after_action", status: "running" }); + const { client, calls } = createClient({ + definitions: [], + listRuns: () => { + listRunsCallCount++; + return listRunsCallCount === 1 ? firstRuns.promise : Promise.resolve([afterActionRun]); + }, + }); + const store = createFastStore(); + store.setClient(client); + + const unsubscribe = store.subscribeWorkspace("workspace-1", () => undefined); + await waitForStoreSnapshot(() => calls.listRuns.mock.calls.length === 1); + store.invalidateWorkspace("workspace-1"); + firstRuns.resolve([]); + + await waitForStoreSnapshot(() => store.getWorkspaceSnapshot("workspace-1").runs.length === 1); + + expect(calls.listRuns).toHaveBeenCalledTimes(2); + expect(store.getWorkspaceSnapshot("workspace-1").runs[0]?.id).toBe("wfr_after_action"); + + unsubscribe(); + store.dispose(); + }); + + test("preserves cached data and surfaces an error when list refreshes fail", async () => { + let listDefinitionsCallCount = 0; + let listRunsCallCount = 0; + const cachedDefinition = definition("project-flow", "project"); + const cachedRun = run({ id: "wfr_cached", status: "running" }); + const { client } = createClient({ + listDefinitions: () => { + listDefinitionsCallCount++; + return listDefinitionsCallCount === 1 + ? Promise.resolve([cachedDefinition]) + : Promise.reject(new Error("definitions unavailable")); + }, + listRuns: () => { + listRunsCallCount++; + return listRunsCallCount === 1 + ? Promise.resolve([cachedRun]) + : Promise.reject(new Error("runs unavailable")); + }, + }); + const store = createFastStore(); + store.setClient(client); + + const unsubscribe = store.subscribeWorkspace("workspace-1", () => undefined); + await waitForStoreSnapshot(() => store.getWorkspaceSnapshot("workspace-1").runs.length === 1); + store.invalidateWorkspace("workspace-1"); + + await waitForStoreSnapshot(() => store.getWorkspaceSnapshot("workspace-1").error != null); + const failedRefreshSnapshot = store.getWorkspaceSnapshot("workspace-1"); + + expect(failedRefreshSnapshot.definitions).toEqual([cachedDefinition]); + expect(failedRefreshSnapshot.runs.map((candidate) => candidate.id)).toContain("wfr_cached"); + expect(failedRefreshSnapshot.error).toContain("definitions unavailable"); + expect(failedRefreshSnapshot.error).toContain("runs unavailable"); + + unsubscribe(); + store.dispose(); + }); + + test("keeps polling active runs after null or rejected getRun responses", async () => { + const { client, calls } = createClient({ + definitions: [], + runs: [run({ id: "wfr_running", status: "running" })], + runDetails: [ + null, + new Error("transient transport failure"), + run({ id: "wfr_running", status: "completed" }), + ], + }); + const store = createFastStore(); + store.setClient(client); + + const unsubscribe = store.subscribeWorkspace("workspace-1", () => undefined); + await waitForStoreSnapshot( + () => store.getWorkspaceSnapshot("workspace-1").runs[0]?.status === "completed" + ); + + expect(calls.getRun).toHaveBeenCalledTimes(3); + expect(store.getWorkspaceSnapshot("workspace-1").error).toBeNull(); + + unsubscribe(); + store.dispose(); + }); }); diff --git a/src/browser/features/Workflows/WorkflowStore.ts b/src/browser/features/Workflows/WorkflowStore.ts index 5142b36435..a5d65cc5be 100644 --- a/src/browser/features/Workflows/WorkflowStore.ts +++ b/src/browser/features/Workflows/WorkflowStore.ts @@ -1,4 +1,4 @@ -import { useContext, useEffect, useSyncExternalStore } from "react"; +import { useCallback, useContext, useEffect, useSyncExternalStore } from "react"; import { APIContext, type APIClient } from "@/browser/contexts/API"; import assert from "@/common/utils/assert"; @@ -12,6 +12,12 @@ import { } from "./workflowStatusPresentation"; export const WORKFLOW_RUN_POLL_INTERVAL_MS = 2000; +export const WORKFLOW_WORKSPACE_REFRESH_INTERVAL_MS = 10_000; + +export interface WorkflowStoreOptions { + runPollIntervalMs?: number; + workspaceRefreshIntervalMs?: number; +} export interface WorkflowWorkspaceSnapshot { definitions: WorkflowDefinitionDescriptor[]; @@ -52,9 +58,24 @@ export class WorkflowStore { private readonly listenersByWorkspace = new Map>(); private readonly states = new Map(); private readonly inFlightSnapshots = new Set(); + private readonly pendingSnapshotRefreshes = new Set(); private readonly runTimers = new Map>(); + private readonly workspaceRefreshTimers = new Map>(); + private readonly runPollIntervalMs: number; + private readonly workspaceRefreshIntervalMs: number; private disposed = false; + constructor(options: WorkflowStoreOptions = {}) { + this.runPollIntervalMs = options.runPollIntervalMs ?? WORKFLOW_RUN_POLL_INTERVAL_MS; + this.workspaceRefreshIntervalMs = + options.workspaceRefreshIntervalMs ?? WORKFLOW_WORKSPACE_REFRESH_INTERVAL_MS; + assert(this.runPollIntervalMs > 0, "Workflow run poll interval must be positive"); + assert( + this.workspaceRefreshIntervalMs > 0, + "Workflow workspace refresh interval must be positive" + ); + } + setClient(client: APIClient | null): void { this.client = client; if (client == null || this.disposed) return; @@ -73,8 +94,14 @@ export class WorkflowStore { } listeners.add(listener); - if (!this.states.has(workspaceId) && !this.inFlightSnapshots.has(workspaceId)) { + const state = this.states.get(workspaceId); + if (state == null && !this.inFlightSnapshots.has(workspaceId)) { queueMicrotask(() => this.refreshWorkspace(workspaceId)); + } else if (state != null) { + // React may unsubscribe/resubscribe external stores during ordinary commits. Keep the + // cached snapshot for layout stability, but restart polling for active cached runs. + this.syncRunPolling(workspaceId, state.runs); + this.scheduleWorkspaceRefresh(workspaceId); } return () => { @@ -82,7 +109,9 @@ export class WorkflowStore { currentListeners?.delete(listener); if (currentListeners?.size === 0) { this.listenersByWorkspace.delete(workspaceId); + this.pendingSnapshotRefreshes.delete(workspaceId); this.stopWorkspaceRunPolling(workspaceId); + this.clearWorkspaceRefreshTimer(workspaceId); } }; }; @@ -97,22 +126,31 @@ export class WorkflowStore { } invalidateWorkspace(workspaceId: string): void { - this.refreshWorkspace(workspaceId); + this.refreshWorkspace(workspaceId, { queueIfInFlight: true }); } dispose(): void { this.disposed = true; for (const timer of this.runTimers.values()) clearTimeout(timer); + for (const timer of this.workspaceRefreshTimers.values()) clearTimeout(timer); this.runTimers.clear(); + this.workspaceRefreshTimers.clear(); this.listenersByWorkspace.clear(); this.states.clear(); this.inFlightSnapshots.clear(); + this.pendingSnapshotRefreshes.clear(); } - private refreshWorkspace(workspaceId: string): void { + private refreshWorkspace(workspaceId: string, options: { queueIfInFlight?: boolean } = {}): void { if (this.client == null || this.disposed || !this.listenersByWorkspace.has(workspaceId)) return; - if (this.inFlightSnapshots.has(workspaceId)) return; + if (this.inFlightSnapshots.has(workspaceId)) { + if (options.queueIfInFlight === true) { + this.pendingSnapshotRefreshes.add(workspaceId); + } + return; + } + this.clearWorkspaceRefreshTimer(workspaceId); this.inFlightSnapshots.add(workspaceId); this.updateWorkspaceLoading(workspaceId, true, null); void this.loadWorkspaceSnapshot(workspaceId); @@ -122,25 +160,38 @@ export class WorkflowStore { assert(this.client != null, "WorkflowStore cannot load workflows without an API client"); const client = this.client; try { - const [definitions, runs] = await Promise.all([ - Promise.resolve() - .then(() => client.workflows.listDefinitions({ workspaceId })) - .catch(() => []), - Promise.resolve() - .then(() => client.workflows.listRuns({ workspaceId })) - .catch(() => []), + const [definitionsResult, runsResult] = await Promise.allSettled([ + client.workflows.listDefinitions({ workspaceId }), + client.workflows.listRuns({ workspaceId }), ]); if (this.disposed) return; - this.setWorkspaceData(workspaceId, definitions, runs, false, null); - } catch (error) { - if (this.disposed) return; - this.updateWorkspaceLoading( + + const current = this.states.get(workspaceId); + const errors: string[] = []; + const definitions = readSettledWorkflowValue( + definitionsResult, + current?.definitions ?? [], + "definitions", + errors + ); + const runs = readSettledWorkflowValue(runsResult, current?.runs ?? [], "runs", errors); + this.setWorkspaceData( workspaceId, + definitions, + runs, false, - error instanceof Error ? error.message : "Failed to load workflows" + errors.length > 0 ? `Failed to load workflows: ${errors.join("; ")}` : null ); + } catch (error) { + if (this.disposed) return; + this.updateWorkspaceLoading(workspaceId, false, getWorkflowErrorMessage(error)); } finally { this.inFlightSnapshots.delete(workspaceId); + if (this.pendingSnapshotRefreshes.delete(workspaceId)) { + queueMicrotask(() => this.refreshWorkspace(workspaceId)); + } else { + this.scheduleWorkspaceRefresh(workspaceId); + } } } @@ -189,6 +240,7 @@ export class WorkflowStore { }; this.states.set(workspaceId, { definitions, runs: orderedRuns, snapshot, isLoading, error }); this.syncRunPolling(workspaceId, orderedRuns); + this.scheduleWorkspaceRefresh(workspaceId); this.emit(workspaceId); } @@ -219,7 +271,7 @@ export class WorkflowStore { const timer = setTimeout(() => { this.runTimers.delete(key); void this.pollRun(workspaceId, runId); - }, WORKFLOW_RUN_POLL_INTERVAL_MS); + }, this.runPollIntervalMs); if (typeof timer === "object" && "unref" in timer && typeof timer.unref === "function") { timer.unref(); } @@ -228,21 +280,62 @@ export class WorkflowStore { private async pollRun(workspaceId: string, runId: string): Promise { if (this.client == null || this.disposed || !this.listenersByWorkspace.has(workspaceId)) return; - const run = await this.client.workflows.getRun({ workspaceId, runId }); - if (this.disposed || !this.listenersByWorkspace.has(workspaceId) || run == null) return; + try { + const run = await this.client.workflows.getRun({ workspaceId, runId }); + if (this.disposed || !this.listenersByWorkspace.has(workspaceId)) return; + if (run == null) { + this.scheduleRunPollIfStillActive(workspaceId, runId); + return; + } - const state = this.states.get(workspaceId); - const currentRuns = state?.runs ?? []; - const nextRuns = currentRuns.some((candidate) => candidate.id === run.id) - ? currentRuns.map((candidate) => (candidate.id === run.id ? run : candidate)) - : [run, ...currentRuns]; - this.setWorkspaceData( - workspaceId, - state?.definitions ?? [], - nextRuns, - state?.isLoading ?? false, - null - ); + const state = this.states.get(workspaceId); + const currentRuns = state?.runs ?? []; + const nextRuns = currentRuns.some((candidate) => candidate.id === run.id) + ? currentRuns.map((candidate) => (candidate.id === run.id ? run : candidate)) + : [run, ...currentRuns]; + this.setWorkspaceData( + workspaceId, + state?.definitions ?? [], + nextRuns, + state?.isLoading ?? false, + null + ); + } catch (error) { + if (this.disposed || !this.listenersByWorkspace.has(workspaceId)) return; + this.updateWorkspaceLoading( + workspaceId, + this.states.get(workspaceId)?.isLoading ?? false, + `Failed to refresh workflow run: ${getWorkflowErrorMessage(error)}` + ); + this.scheduleRunPollIfStillActive(workspaceId, runId); + } + } + + private scheduleRunPollIfStillActive(workspaceId: string, runId: string): void { + if (!this.listenersByWorkspace.has(workspaceId)) return; + const run = this.states.get(workspaceId)?.runs.find((candidate) => candidate.id === runId); + if (run == null || summarizeWorkflowRuns([run]).activeCount === 0) return; + this.scheduleRunPoll(workspaceId, runId); + } + + private scheduleWorkspaceRefresh(workspaceId: string): void { + if ( + this.disposed || + !this.listenersByWorkspace.has(workspaceId) || + this.inFlightSnapshots.has(workspaceId) || + this.workspaceRefreshTimers.has(workspaceId) + ) { + return; + } + + const timer = setTimeout(() => { + this.workspaceRefreshTimers.delete(workspaceId); + this.refreshWorkspace(workspaceId); + }, this.workspaceRefreshIntervalMs); + if (typeof timer === "object" && "unref" in timer && typeof timer.unref === "function") { + timer.unref(); + } + this.workspaceRefreshTimers.set(workspaceId, timer); } private stopWorkspaceRunPolling(workspaceId: string): void { @@ -259,6 +352,12 @@ export class WorkflowStore { this.runTimers.delete(key); } + private clearWorkspaceRefreshTimer(workspaceId: string): void { + const timer = this.workspaceRefreshTimers.get(workspaceId); + if (timer != null) clearTimeout(timer); + this.workspaceRefreshTimers.delete(workspaceId); + } + private emit(workspaceId: string): void { const listeners = this.listenersByWorkspace.get(workspaceId); if (listeners == null) return; @@ -266,6 +365,21 @@ export class WorkflowStore { } } +function readSettledWorkflowValue( + result: PromiseSettledResult, + fallback: T, + label: string, + errors: string[] +): T { + if (result.status === "fulfilled") return result.value; + errors.push(`${label}: ${getWorkflowErrorMessage(result.reason)}`); + return fallback; +} + +function getWorkflowErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown workflow error"; +} + function areWorkflowSummariesEqual( left: WorkflowRunsSummary | undefined, right: WorkflowRunsSummary @@ -307,8 +421,14 @@ export function useWorkflowWorkspaceSummary(workspaceId: string | undefined): Wo store.setClient(api); }, [api, store]); + const subscribe = useCallback( + (listener: Listener) => + workspaceId ? store.subscribeWorkspace(workspaceId, listener) : () => undefined, + [store, workspaceId] + ); + return useSyncExternalStore( - (listener) => (workspaceId ? store.subscribeWorkspace(workspaceId, listener) : () => undefined), + subscribe, () => store.getWorkspaceSummary(workspaceId), () => EMPTY_SUMMARY ); @@ -325,8 +445,14 @@ export function useWorkflowWorkspaceSnapshot( store.setClient(api); }, [api, store]); + const subscribe = useCallback( + (listener: Listener) => + workspaceId ? store.subscribeWorkspace(workspaceId, listener) : () => undefined, + [store, workspaceId] + ); + return useSyncExternalStore( - (listener) => (workspaceId ? store.subscribeWorkspace(workspaceId, listener) : () => undefined), + subscribe, () => store.getWorkspaceSnapshot(workspaceId), () => EMPTY_SNAPSHOT ); diff --git a/src/browser/features/Workflows/WorkflowsTab.test.tsx b/src/browser/features/Workflows/WorkflowsTab.test.tsx index 32a65634e9..aaa332d001 100644 --- a/src/browser/features/Workflows/WorkflowsTab.test.tsx +++ b/src/browser/features/Workflows/WorkflowsTab.test.tsx @@ -4,6 +4,8 @@ import { installDom } from "../../../../tests/ui/dom"; import type { WorkflowDefinitionDescriptor, WorkflowRunRecord } from "@/common/types/workflow"; +import { WORKFLOW_CHECKPOINT_RETRY_ERROR_MESSAGE } from "@/common/utils/workflowRetryEligibility"; + import { WorkflowsTabView } from "./WorkflowsTab"; import { groupWorkflowDefinitionsByScope, @@ -113,7 +115,7 @@ describe("WorkflowsTabView", () => { expect(view.getByText("Recent history")).toBeTruthy(); }); - test("offers foreground and background run actions", () => { + test("starts workflow definitions in the background from the sidebar", () => { const started: Array<{ name: string; runInBackground: boolean }> = []; const project = definition("project-flow", "project"); const view = render( @@ -129,12 +131,9 @@ describe("WorkflowsTabView", () => { ); fireEvent.click(view.getByRole("button", { name: "Run project-flow" })); - fireEvent.click(view.getByRole("button", { name: "Run project-flow in background" })); - expect(started).toEqual([ - { name: "project-flow", runInBackground: false }, - { name: "project-flow", runInBackground: true }, - ]); + expect(view.queryByRole("button", { name: "Run project-flow in background" })).toBeNull(); + expect(started).toEqual([{ name: "project-flow", runInBackground: true }]); }); test("offers supported current-run actions", () => { @@ -158,6 +157,14 @@ describe("WorkflowsTabView", () => { id: "wfr_failed", status: "failed", definition: definition("failed-flow", "project"), + events: [ + { + type: "error", + at: "2026-01-01T00:00:01.000Z", + sequence: 1, + message: WORKFLOW_CHECKPOINT_RETRY_ERROR_MESSAGE, + }, + ], }); const completed = run({ id: "wfr_completed", @@ -189,6 +196,30 @@ describe("WorkflowsTabView", () => { ]); }); + test("does not offer checkpoint retry for ordinary failed runs", () => { + const failed = run({ + id: "wfr_failed", + status: "failed", + definition: definition("failed-flow", "project"), + events: [ + { + type: "error", + at: "2026-01-01T00:00:01.000Z", + sequence: 1, + message: "Compilation failed", + }, + ], + }); + const view = render( + undefined} + /> + ); + + expect(view.queryByRole("button", { name: "Retry failed-flow" })).toBeNull(); + }); + test("offers promotion actions for scratch definitions", () => { const promoted: Array<{ name: string; location: "project" | "global" }> = []; const scratch = definition("scratch-flow", "scratch"); diff --git a/src/browser/features/Workflows/WorkflowsTab.tsx b/src/browser/features/Workflows/WorkflowsTab.tsx index f194b5433f..6424256f82 100644 --- a/src/browser/features/Workflows/WorkflowsTab.tsx +++ b/src/browser/features/Workflows/WorkflowsTab.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState } from "react"; +import React, { useContext, useRef, useState } from "react"; import { Play, RefreshCw } from "lucide-react"; import { APIContext } from "@/browser/contexts/API"; @@ -10,6 +10,8 @@ import type { WorkflowRunRecord, } from "@/common/types/workflow"; +import { canRetryWorkflowFromCheckpoint } from "@/common/utils/workflowRetryEligibility"; + import { getWorkflowStoreInstance, type WorkflowWorkspaceSnapshot, @@ -42,6 +44,7 @@ interface WorkflowsTabViewProps { definition: WorkflowDefinitionDescriptor, location: "project" | "global" ) => Promise | void; + pendingDefinitionNames?: ReadonlySet; onRefresh?: () => void; } @@ -67,23 +70,33 @@ const SEVERITY_CLASS: Record = { export function WorkflowsTab(props: WorkflowsTabProps) { const apiState = useContext(APIContext); const snapshot = useWorkflowWorkspaceSnapshot(props.workspaceId); + const pendingDefinitionNamesRef = useRef(new Set()); + const [pendingDefinitionNames, setPendingDefinitionNames] = useState>( + () => new Set() + ); const [actionError, setActionError] = useState(null); const handleRunDefinition = async ( definition: WorkflowDefinitionDescriptor, options?: RunWorkflowOptions ) => { - if (!apiState?.api) return; + if (!apiState?.api || pendingDefinitionNamesRef.current.has(definition.name)) return; + pendingDefinitionNamesRef.current.add(definition.name); + setPendingDefinitionNames(new Set(pendingDefinitionNamesRef.current)); setActionError(null); try { await apiState.api.workflows.start({ workspaceId: props.workspaceId, name: definition.name, - runInBackground: options?.runInBackground === true, + // Sidebar launches should return immediately so progress appears in Current runs. + runInBackground: options?.runInBackground ?? true, }); getWorkflowStoreInstance().invalidateWorkspace(props.workspaceId); } catch (error) { setActionError(error instanceof Error ? error.message : "Failed to start workflow"); + } finally { + pendingDefinitionNamesRef.current.delete(definition.name); + setPendingDefinitionNames(new Set(pendingDefinitionNamesRef.current)); } }; @@ -134,6 +147,7 @@ export function WorkflowsTab(props: WorkflowsTabProps) { onRunDefinition={apiState?.api ? handleRunDefinition : undefined} onRunAction={apiState?.api ? handleRunAction : undefined} onPromoteScratchDefinition={apiState?.api ? handlePromoteScratchDefinition : undefined} + pendingDefinitionNames={pendingDefinitionNames} /> ); @@ -212,6 +226,7 @@ export function WorkflowsTabView(props: WorkflowsTabViewProps) { @@ -251,7 +266,7 @@ function WorkflowRunCard(props: { onAction?: (run: WorkflowRunRecord, action: WorkflowRunAction) => Promise | void; }) { const presentation = getWorkflowStatusPresentation(props.run.status); - const action = getWorkflowRunAction(props.run.status); + const action = getWorkflowRunAction(props.run); return (
{props.onRun && ( - <> - - - + )} {props.definition.scope === "scratch" && props.onPromoteScratchDefinition && ( <> @@ -375,15 +379,15 @@ function WorkflowDefinitionRow(props: { } function getWorkflowRunAction( - status: WorkflowRunRecord["status"] + run: WorkflowRunRecord ): { action: WorkflowRunAction; label: string } | null { - if (status === "pending" || status === "running" || status === "backgrounded") { + if (run.status === "pending" || run.status === "running" || run.status === "backgrounded") { return { action: "interrupt", label: "Interrupt" }; } - if (status === "interrupted") { + if (run.status === "interrupted") { return { action: "resume", label: "Resume" }; } - if (status === "failed") { + if (canRetryWorkflowFromCheckpoint(run)) { return { action: "retryFromCheckpoint", label: "Retry" }; } return null; diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 5a1879ff39..44930d4f08 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -64,6 +64,7 @@ import { getProviderModelEntryId } from "@/common/utils/providers/modelEntries"; import { isCustomOpenAICompatibleProviderConfig } from "@/common/utils/providers/customProviders"; import { isValidProvider } from "@/common/constants/providers"; import { openInEditor } from "@/browser/utils/openInEditor"; +import { getWorkflowStoreInstance } from "@/browser/features/Workflows/WorkflowStore"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; // ============================================================================ @@ -453,6 +454,7 @@ export async function processSlashCommand( continuationOptions: context.sendMessageOptions, rawCommand, }); + getWorkflowStoreInstance().invalidateWorkspace(workspaceId); // The workflow is durable and backgrounded; do not pin the composer while polling for // completion, otherwise the user cannot supersede a long-running slash workflow. setWorkflowSendingState(false); diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index a5dc8f10c8..fdf823a675 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -5,6 +5,7 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { GlobalWindow } from "happy-dom"; import { getModelKey } from "@/common/constants/storage"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import type { WorkspaceState } from "@/browser/stores/WorkspaceStore"; import type { APIClient } from "@/browser/contexts/API"; @@ -257,6 +258,26 @@ test("buildCoreSources includes create/switch workspace actions", () => { expect(titles.includes("Open Terminal Window for Workspace…")).toBe(true); }); +test("right sidebar add-tool options respect enabled feature flags", async () => { + const withoutWorkflows = getActions(); + const withWorkflows = getActions({ + enabledRightSidebarFeatureFlags: new Set([EXPERIMENT_IDS.DYNAMIC_WORKFLOWS]), + }); + + const getToolOptionLabels = async (actions: ReturnType) => { + const addToolAction = actions.find((action) => action.title === "Right Sidebar: Add Tool…"); + const toolField = addToolAction?.prompt?.fields.find((field) => field.name === "tool"); + if (toolField?.type !== "select") { + throw new Error("Expected add-tool select field"); + } + const options = await toolField.getOptions({}); + return options.map((option) => option.label); + }; + + expect(await getToolOptionLabels(withoutWorkflows)).not.toContain("Workflows"); + expect(await getToolOptionLabels(withWorkflows)).toContain("Workflows"); +}); + test("appearance commands offer auto when a manual theme is selected", () => { const actions = getActions({ themePreference: "dark" }); diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 65b58c3705..d4482718a4 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -86,6 +86,7 @@ export interface BuildSourcesParams { getMinThinkingOverride?: (modelString: string) => ThinkingLevel | null | undefined; onStartWorkspaceCreation: (projectPath: string) => void; + enabledRightSidebarFeatureFlags?: ReadonlySet; onStartMultiProjectWorkspaceCreation: () => void; multiProjectWorkspacesEnabled: boolean; onArchiveMergedWorkspacesInProject: (projectPath: string) => Promise; @@ -324,6 +325,11 @@ function openGoalPanel(workspaceId: string, openCompleteInput = false): void { export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandAction[]> { const actions: Array<() => CommandAction[]> = []; + const isRightSidebarBaseTabEnabled = (tabId: BaseTabType): boolean => { + const featureFlag = getTabConfig(tabId).featureFlag; + return featureFlag == null || p.enabledRightSidebarFeatureFlags?.has(featureFlag) === true; + }; + // NOTE: We intentionally route to the chat-based creation flow instead of // building a separate prompt. This keeps `/new`, keybinds, and the command // palette perfectly aligned on one experience. @@ -637,7 +643,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi ...getOrderedBaseTabIds() .filter((tabId) => { const config = getTabConfig(tabId); - return config.inDefaultLayout !== true && config.featureFlag == null; + return config.inDefaultLayout !== true && isRightSidebarBaseTabEnabled(tabId); }) .map((tabId) => buildToggleTabCommand(wsId, tabId, section.navigation)), { @@ -697,7 +703,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi // Terminal is appended manually because it lives outside the static registry. getOptions: () => [ ...getOrderedBaseTabIds() - .filter((tabId) => getTabConfig(tabId).featureFlag == null) + .filter((tabId) => isRightSidebarBaseTabEnabled(tabId)) .map((tabId) => { const config = getTabConfig(tabId); return { From ebe60dbe7bbb39527ea5731dc34d72e329d0c3b8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 8 Jun 2026 18:09:04 +0000 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=A4=96=20test:=20stabilize=20workflow?= =?UTF-8?q?=20indicator=20popover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkflowIndicator/WorkflowIndicator.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx b/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx index 138eea1bd1..d03b732223 100644 --- a/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx +++ b/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { cleanup, fireEvent, render } from "@testing-library/react"; +import { cleanup, fireEvent, render, waitFor, within } from "@testing-library/react"; import { installDom } from "../../../../tests/ui/dom"; import type { WorkflowDefinitionDescriptor, WorkflowRunRecord } from "@/common/types/workflow"; @@ -100,7 +100,7 @@ describe("WorkflowIndicatorView", () => { expect(view.getByLabelText("2 workflows need attention")).toBeTruthy(); }); - test("opens the workflows tab from the popover action", () => { + test("opens the workflows tab from the popover action", async () => { let opened = false; const view = render( @@ -124,7 +124,9 @@ describe("WorkflowIndicatorView", () => { ); fireEvent.click(view.getByLabelText("Workflows")); - fireEvent.click(view.getByText("Open tab")); + const body = within(view.container.ownerDocument.body); + const openTabButton = await waitFor(() => body.getByText("Open tab")); + fireEvent.click(openTabButton); expect(opened).toBe(true); }); From 8400b636ad8028a5474f6889c411356ac795a2b8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 8 Jun 2026 18:43:34 +0000 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stabilize=20workflow?= =?UTF-8?q?=20readiness=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkflowIndicator.test.tsx | 44 ++++----- .../WorkflowIndicator/WorkflowIndicator.tsx | 90 +++++++++++-------- .../workflows/WorkflowRunStore.test.ts | 10 +++ .../services/workflows/WorkflowRunStore.ts | 3 + 4 files changed, 83 insertions(+), 64 deletions(-) diff --git a/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx b/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx index d03b732223..1fe0a93eef 100644 --- a/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx +++ b/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { cleanup, fireEvent, render, waitFor, within } from "@testing-library/react"; +import { cleanup, fireEvent, render } from "@testing-library/react"; import { installDom } from "../../../../tests/ui/dom"; import type { WorkflowDefinitionDescriptor, WorkflowRunRecord } from "@/common/types/workflow"; import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; -import { WorkflowIndicatorView } from "./WorkflowIndicator"; +import { WorkflowIndicatorPopoverContent, WorkflowIndicatorView } from "./WorkflowIndicator"; import { groupWorkflowDefinitionsByScope, summarizeWorkflowRuns, @@ -100,33 +100,27 @@ describe("WorkflowIndicatorView", () => { expect(view.getByLabelText("2 workflows need attention")).toBeTruthy(); }); - test("opens the workflows tab from the popover action", async () => { + test("opens the workflows tab from the popover action", () => { let opened = false; const view = render( - - { - opened = true; - }} - snapshot={{ - definitions: [], - definitionGroups: groupWorkflowDefinitionsByScope([]), - runs: [], - currentRuns: [], - historyRuns: [], - summary: summarizeWorkflowRuns([]), - isLoading: false, - error: null, - }} - /> - + { + opened = true; + }} + snapshot={{ + definitions: [], + definitionGroups: groupWorkflowDefinitionsByScope([]), + runs: [], + currentRuns: [], + historyRuns: [], + summary: summarizeWorkflowRuns([]), + isLoading: false, + error: null, + }} + /> ); - fireEvent.click(view.getByLabelText("Workflows")); - const body = within(view.container.ownerDocument.body); - const openTabButton = await waitFor(() => body.getByText("Open tab")); - fireEvent.click(openTabButton); + fireEvent.click(view.getByText("Open tab")); expect(opened).toBe(true); }); diff --git a/src/browser/components/WorkflowIndicator/WorkflowIndicator.tsx b/src/browser/components/WorkflowIndicator/WorkflowIndicator.tsx index b322a9e38e..0cdb92328e 100644 --- a/src/browser/components/WorkflowIndicator/WorkflowIndicator.tsx +++ b/src/browser/components/WorkflowIndicator/WorkflowIndicator.tsx @@ -83,50 +83,62 @@ export function WorkflowIndicatorView(props: WorkflowIndicatorViewProps) { align="end" className="bg-modal-bg border-separator-light w-72 overflow-visible rounded px-3 py-2 text-xs font-normal shadow-[0_2px_8px_rgba(0,0,0,0.4)]" > -
-
-
Workflows
- -
- - {props.snapshot.currentRuns.length === 0 ? ( -

No active workflows

- ) : ( - props.snapshot.currentRuns.slice(0, 5).map((run) => { - const presentation = getWorkflowStatusPresentation(run.status); - return ( -
- {run.definition.name} - {presentation.label} -
- ); - }) - )} -
- - {props.snapshot.definitions.length === 0 ? ( -

No definitions found

- ) : ( -

- {props.snapshot.definitionGroups.project.length} project ·{" "} - {props.snapshot.definitionGroups.global.length} global ·{" "} - {props.snapshot.definitionGroups["built-in"].length} built-in ·{" "} - {props.snapshot.definitionGroups.scratch.length} scratch -

- )} -
-
+ ); } +export function WorkflowIndicatorPopoverContent(props: { + snapshot: WorkflowWorkspaceSnapshot; + onOpenWorkflowsTab: () => void; +}) { + return ( +
+
+
Workflows
+ +
+ + {props.snapshot.currentRuns.length === 0 ? ( +

No active workflows

+ ) : ( + props.snapshot.currentRuns.slice(0, 5).map((run) => { + const presentation = getWorkflowStatusPresentation(run.status); + return ( +
+ {run.definition.name} + {presentation.label} +
+ ); + }) + )} +
+ + {props.snapshot.definitions.length === 0 ? ( +

No definitions found

+ ) : ( +

+ {props.snapshot.definitionGroups.project.length} project ·{" "} + {props.snapshot.definitionGroups.global.length} global ·{" "} + {props.snapshot.definitionGroups["built-in"].length} built-in ·{" "} + {props.snapshot.definitionGroups.scratch.length} scratch +

+ )} +
+
+ ); +} + function IndicatorSection(props: { title: string; children: React.ReactNode }) { return (
diff --git a/src/node/services/workflows/WorkflowRunStore.test.ts b/src/node/services/workflows/WorkflowRunStore.test.ts index 7c8e1a72c0..2d0573abf1 100644 --- a/src/node/services/workflows/WorkflowRunStore.test.ts +++ b/src/node/services/workflows/WorkflowRunStore.test.ts @@ -387,6 +387,16 @@ describe("WorkflowRunStore", () => { }); }); + test("creates the run directory before acquiring a lease lock", async () => { + using tmp = new DisposableTempDir("workflow-runs"); + const store = new WorkflowRunStore({ sessionDir: tmp.path }); + + await expect(store.acquireLease("wfr_missing", "runner-a", 1000)).resolves.toBe(true); + await expect( + fs.readFile(path.join(tmp.path, "workflows", "wfr_missing", "lease.json"), "utf-8") + ).resolves.toContain("runner-a"); + }); + test("renews active leases so they are not reclaimed as stale", async () => { using tmp = new DisposableTempDir("workflow-runs"); const store = await createStore(tmp.path); diff --git a/src/node/services/workflows/WorkflowRunStore.ts b/src/node/services/workflows/WorkflowRunStore.ts index f1e4bed3d1..5340b1be7f 100644 --- a/src/node/services/workflows/WorkflowRunStore.ts +++ b/src/node/services/workflows/WorkflowRunStore.ts @@ -485,6 +485,9 @@ export class WorkflowRunStore { assert(ownerId.length > 0, "WorkflowRunStore.acquireLease: ownerId is required"); const leaseFile = this.leaseFile(runId); const lockDir = `${leaseFile}.lock`; + // The lock itself must remain a non-recursive mkdir for atomicity, but its parent can be + // absent during resume/crash-recovery races in tests or after partial run directory repair. + await fs.mkdir(path.dirname(lockDir), { recursive: true }); if (!(await acquireLeaseMutationLock(lockDir, Date.now(), this.leaseMutationLockStaleMs()))) { return false; } From 2395a8ceb2594ae183322ba1b12ae133448f5bbe Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 8 Jun 2026 18:49:21 +0000 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20sidebar=20?= =?UTF-8?q?feature=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/App.tsx | 12 +++++++++--- src/browser/utils/commands/sources.test.ts | 19 +++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 9765f9da8a..e416391561 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -190,7 +190,9 @@ function AppInner() { ); const [isMultiProjectWorkspaceModalOpen, setMultiProjectWorkspaceModalOpen] = useState(false); + const agentBrowserEnabled = useExperimentValue(EXPERIMENT_IDS.AGENT_BROWSER); const dynamicWorkflowsEnabled = useExperimentValue(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS); + const portableDesktopEnabled = useExperimentValue(EXPERIMENT_IDS.PORTABLE_DESKTOP); const multiProjectWorkspacesEnabled = useExperimentValue(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES); // Left sidebar is drag-resizable (mirrors RightSidebar). Width is persisted globally; @@ -709,6 +711,12 @@ function AppInner() { [handleNavigateWorkspace] ); + const enabledRightSidebarFeatureFlags = new Set(); + if (agentBrowserEnabled) enabledRightSidebarFeatureFlags.add(EXPERIMENT_IDS.AGENT_BROWSER); + if (dynamicWorkflowsEnabled) + enabledRightSidebarFeatureFlags.add(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS); + if (portableDesktopEnabled) enabledRightSidebarFeatureFlags.add(EXPERIMENT_IDS.PORTABLE_DESKTOP); + registerParamsRef.current = { userProjects, workspaceMetadata, @@ -718,9 +726,7 @@ function AppInner() { onSetThinkingLevel: setThinkingLevelFromPalette, getMinThinkingOverride, onStartWorkspaceCreation: openNewWorkspaceFromPalette, - enabledRightSidebarFeatureFlags: dynamicWorkflowsEnabled - ? new Set([EXPERIMENT_IDS.DYNAMIC_WORKFLOWS]) - : undefined, + enabledRightSidebarFeatureFlags, onStartMultiProjectWorkspaceCreation: openNewMultiProjectWorkspaceFromPalette, multiProjectWorkspacesEnabled, onArchiveMergedWorkspacesInProject: archiveMergedWorkspacesInProjectFromPalette, diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index fdf823a675..00886e71bc 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -259,10 +259,17 @@ test("buildCoreSources includes create/switch workspace actions", () => { }); test("right sidebar add-tool options respect enabled feature flags", async () => { - const withoutWorkflows = getActions(); - const withWorkflows = getActions({ + const withoutEnabledFlags = getActions(); + const withWorkflowFlag = getActions({ enabledRightSidebarFeatureFlags: new Set([EXPERIMENT_IDS.DYNAMIC_WORKFLOWS]), }); + const withAllFlaggedSidebarTabs = getActions({ + enabledRightSidebarFeatureFlags: new Set([ + EXPERIMENT_IDS.AGENT_BROWSER, + EXPERIMENT_IDS.DYNAMIC_WORKFLOWS, + EXPERIMENT_IDS.PORTABLE_DESKTOP, + ]), + }); const getToolOptionLabels = async (actions: ReturnType) => { const addToolAction = actions.find((action) => action.title === "Right Sidebar: Add Tool…"); @@ -274,8 +281,12 @@ test("right sidebar add-tool options respect enabled feature flags", async () => return options.map((option) => option.label); }; - expect(await getToolOptionLabels(withoutWorkflows)).not.toContain("Workflows"); - expect(await getToolOptionLabels(withWorkflows)).toContain("Workflows"); + expect(await getToolOptionLabels(withoutEnabledFlags)).not.toContain("Workflows"); + expect(await getToolOptionLabels(withWorkflowFlag)).toContain("Workflows"); + const allFlaggedSidebarLabels = await getToolOptionLabels(withAllFlaggedSidebarTabs); + expect(allFlaggedSidebarLabels).toContain("Browser"); + expect(allFlaggedSidebarLabels).toContain("Desktop"); + expect(allFlaggedSidebarLabels).toContain("Workflows"); }); test("appearance commands offer auto when a manual theme is selected", () => {