diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 1cca2d9c7c..e416391561 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -190,6 +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; @@ -708,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, @@ -717,6 +726,7 @@ function AppInner() { onSetThinkingLevel: setThinkingLevelFromPalette, getMinThinkingOverride, onStartWorkspaceCreation: openNewWorkspaceFromPalette, + enabledRightSidebarFeatureFlags, onStartMultiProjectWorkspaceCreation: openNewMultiProjectWorkspaceFromPalette, multiProjectWorkspacesEnabled, onArchiveMergedWorkspacesInProject: archiveMergedWorkspacesInProjectFromPalette, diff --git a/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx b/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx new file mode 100644 index 0000000000..1fe0a93eef --- /dev/null +++ b/src/browser/components/WorkflowIndicator/WorkflowIndicator.test.tsx @@ -0,0 +1,127 @@ +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 { WorkflowIndicatorPopoverContent, 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.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..0cdb92328e --- /dev/null +++ b/src/browser/components/WorkflowIndicator/WorkflowIndicator.tsx @@ -0,0 +1,151 @@ +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} + + + + + + + ); +} + +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 ( +
+

+ {props.title} +

+ {props.children} +
+ ); +} diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx index ec33625d68..a33a90afdb 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"; @@ -95,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(); @@ -683,6 +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); @@ -1109,6 +1123,21 @@ const RightSidebarComponent: 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 || !workflowsExperimentEnabled) { + 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, workflowsExperimentEnabled, 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..4a707ab5fc 100644 --- a/src/browser/features/RightSidebar/Tabs/tabConfig.ts +++ b/src/browser/features/RightSidebar/Tabs/tabConfig.ts @@ -53,6 +53,13 @@ const TAB_CONFIG_DEF = { defaultOrder: 35, paletteKeywords: ["goal", "target", "objective"], }, + workflows: { + name: "Workflows", + contentClassName: "overflow-hidden p-0", + featureFlag: EXPERIMENT_IDS.DYNAMIC_WORKFLOWS, + 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..9c247313cd 100644 --- a/src/browser/features/Tools/WorkflowRunToolCall.tsx +++ b/src/browser/features/Tools/WorkflowRunToolCall.tsx @@ -60,6 +60,10 @@ import { formatWorkflowSavedMessage, type WorkflowPromotionTarget, } from "./WorkflowDefinitionToolCall"; +import { + getWorkflowStoreInstance, + useWorkflowWorkspaceSnapshot, +} from "@/browser/features/Workflows/WorkflowStore"; import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { MarkdownRenderer } from "../Messages/MarkdownRenderer"; @@ -115,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); @@ -885,24 +890,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 +958,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; @@ -1085,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, @@ -1102,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) => { @@ -1251,7 +1260,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 +1297,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..bffaabff98 --- /dev/null +++ b/src/browser/features/Workflows/WorkflowStore.test.ts @@ -0,0 +1,303 @@ +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 { 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 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?: Array; + listDefinitions?: () => Promise; + listRuns?: () => Promise; +}) { + const calls = { + 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: { + listDefinitions: calls.listDefinitions, + listRuns: calls.listRuns, + getRun: calls.getRun, + }, + } as unknown as APIClient; + 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 < 80; attempt++) { + if (predicate()) return; + await Bun.sleep(2); + } + 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 = createFastStore(); + 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 = 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(1); + expect(store.getWorkspaceSnapshot("workspace-1").runs[0]?.status).toBe("completed"); + + unsubscribe(); + 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({ + 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 = 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 waitForStoreSnapshot( + () => store.getWorkspaceSnapshot("workspace-1").runs[0]?.events.length === 1 + ); + + expect(store.getWorkspaceSummary("workspace-1")).toBe(initialSummary); + + 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 new file mode 100644 index 0000000000..a5d65cc5be --- /dev/null +++ b/src/browser/features/Workflows/WorkflowStore.ts @@ -0,0 +1,459 @@ +import { useCallback, 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 const WORKFLOW_WORKSPACE_REFRESH_INTERVAL_MS = 10_000; + +export interface WorkflowStoreOptions { + runPollIntervalMs?: number; + workspaceRefreshIntervalMs?: number; +} + +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 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; + + 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); + + 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 () => { + const currentListeners = this.listenersByWorkspace.get(workspaceId); + currentListeners?.delete(listener); + if (currentListeners?.size === 0) { + this.listenersByWorkspace.delete(workspaceId); + this.pendingSnapshotRefreshes.delete(workspaceId); + this.stopWorkspaceRunPolling(workspaceId); + this.clearWorkspaceRefreshTimer(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, { 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, options: { queueIfInFlight?: boolean } = {}): void { + if (this.client == null || this.disposed || !this.listenersByWorkspace.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); + } + + private async loadWorkspaceSnapshot(workspaceId: string): Promise { + assert(this.client != null, "WorkflowStore cannot load workflows without an API client"); + const client = this.client; + try { + const [definitionsResult, runsResult] = await Promise.allSettled([ + client.workflows.listDefinitions({ workspaceId }), + client.workflows.listRuns({ workspaceId }), + ]); + if (this.disposed) return; + + 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, + 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); + } + } + } + + 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.scheduleWorkspaceRefresh(workspaceId); + 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); + }, this.runPollIntervalMs); + 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; + 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 + ); + } 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 { + 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 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; + for (const listener of listeners) listener(); + } +} + +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 +): 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]); + + const subscribe = useCallback( + (listener: Listener) => + workspaceId ? store.subscribeWorkspace(workspaceId, listener) : () => undefined, + [store, workspaceId] + ); + + return useSyncExternalStore( + subscribe, + () => 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]); + + const subscribe = useCallback( + (listener: Listener) => + workspaceId ? store.subscribeWorkspace(workspaceId, listener) : () => undefined, + [store, workspaceId] + ); + + return useSyncExternalStore( + subscribe, + () => 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..aaa332d001 --- /dev/null +++ b/src/browser/features/Workflows/WorkflowsTab.test.tsx @@ -0,0 +1,243 @@ +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 { WORKFLOW_CHECKPOINT_RETRY_ERROR_MESSAGE } from "@/common/utils/workflowRetryEligibility"; + +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("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( + { + started.push({ + name: definition.name, + runInBackground: options?.runInBackground === true, + }); + }} + /> + ); + + fireEvent.click(view.getByRole("button", { name: "Run project-flow" })); + + 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", () => { + 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"), + events: [ + { + type: "error", + at: "2026-01-01T00:00:01.000Z", + sequence: 1, + message: WORKFLOW_CHECKPOINT_RETRY_ERROR_MESSAGE, + }, + ], + }); + 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("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"); + 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..6424256f82 --- /dev/null +++ b/src/browser/features/Workflows/WorkflowsTab.tsx @@ -0,0 +1,415 @@ +import React, { useContext, useRef, 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 { canRetryWorkflowFromCheckpoint } from "@/common/utils/workflowRetryEligibility"; + +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; + pendingDefinitionNames?: ReadonlySet; + 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 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 || 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, + // 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)); + } + }; + + 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); + return ( +
+
+
+
{props.run.definition.name}
+ {!props.compact && ( +
+ {getLatestWorkflowRunSummary(props.run)} +
+ )} +
+
+ + {!props.compact && props.onAction && action && ( + + )} +
+
+
{props.run.id}
+
+ ); +} + +function WorkflowDefinitionRow(props: { + definition: WorkflowDefinitionDescriptor; + isStarting?: boolean; + 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( + run: WorkflowRunRecord +): { action: WorkflowRunAction; label: string } | null { + if (run.status === "pending" || run.status === "running" || run.status === "backgrounded") { + return { action: "interrupt", label: "Interrupt" }; + } + if (run.status === "interrupted") { + return { action: "resume", label: "Resume" }; + } + if (canRetryWorkflowFromCheckpoint(run)) { + 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/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..00886e71bc 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,37 @@ 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 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…"); + 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(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", () => { 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 { 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)); 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; }