From e8bcc31836dea98aec9d7c4157ca1eca7cf26636 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 15 Jun 2026 14:02:06 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=A4=96=20feat:=20centralize=20workflo?= =?UTF-8?q?w=20automations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote scheduled workflow automations from workspace-local state into project-level schedules that survive workspace archival and deletion. - add project workflow schedule schemas, oRPC endpoints, and scheduler dispatch support - support existing-workspace and new-workspace automation targets with conflict validation - add modal UI, menu entries, stories, and tests for configuring and running automations - harden workflow scheduling around terminal status, stale config stamps, and unsupported targets - remove unshipped legacy workspace automation migration plumbing --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$632.46`_ --- .../AgentListItem/AgentListItem.stories.tsx | 25 +- .../AgentListItem/AgentListItem.test.tsx | 15 + .../AgentListItem/AgentListItem.tsx | 23 + .../AutomationModal.stories.tsx | 143 +++ .../AutomationModal/AutomationModal.test.tsx | 446 +++++++ .../AutomationModal/AutomationModal.tsx | 595 +++++++++ .../components/AutomationModal/index.ts | 1 + .../PositionedMenu/PositionedMenu.tsx | 4 +- .../ProjectAutomationsModal.test.tsx | 476 +++++++ .../ProjectAutomationsModal.tsx | 1100 +++++++++++++++++ .../ProjectSidebar/ProjectSidebar.stories.tsx | 81 ++ .../ProjectSidebar/ProjectSidebar.test.tsx | 85 ++ .../ProjectSidebar/ProjectSidebar.tsx | 79 ++ .../WorkspaceActionsMenuContent.test.tsx | 43 + .../WorkspaceActionsMenuContent.tsx | 26 +- .../WorkspaceMenuBar.test.tsx | 180 ++- .../WorkspaceMenuBar/WorkspaceMenuBar.tsx | 54 +- .../Settings/Sections/KeybindsSection.tsx | 9 +- src/browser/stories/mocks/orpc.ts | 61 + src/browser/utils/projectWorkflowSchedules.ts | 16 + src/browser/utils/ui/keybinds.ts | 8 + .../utils/workflowScheduleIntervalMinutes.ts | 109 ++ src/common/orpc/schemas.ts | 2 + src/common/orpc/schemas/api.ts | 54 +- src/common/orpc/schemas/project.ts | 6 +- src/common/orpc/schemas/workspace.ts | 48 + src/common/schemas/project.ts | 74 ++ src/common/types/project.ts | 2 + src/common/utils/workflowSchedule.ts | 30 + .../utils/workflowScheduleTarget.test.ts | 59 + src/common/utils/workflowScheduleTarget.ts | 86 ++ src/constants/workflowSchedule.ts | 11 + src/node/config.ts | 24 +- src/node/orpc/context.ts | 2 + src/node/orpc/router.test.ts | 309 ++++- src/node/orpc/router.ts | 272 +++- src/node/services/serviceContainer.test.ts | 327 +++++ src/node/services/serviceContainer.ts | 227 +++- .../WorkflowSchedulerService.test.ts | 529 +++++++- .../workflows/WorkflowSchedulerService.ts | 637 +++++++++- .../workflows/WorkflowService.test.ts | 68 + .../services/workflows/WorkflowService.ts | 32 +- src/node/services/workspaceService.ts | 158 ++- .../workspaceService.workflowSchedule.test.ts | 160 ++- src/node/utils/projectTrust.ts | 8 +- 45 files changed, 6592 insertions(+), 112 deletions(-) create mode 100644 src/browser/components/AutomationModal/AutomationModal.stories.tsx create mode 100644 src/browser/components/AutomationModal/AutomationModal.test.tsx create mode 100644 src/browser/components/AutomationModal/AutomationModal.tsx create mode 100644 src/browser/components/AutomationModal/index.ts create mode 100644 src/browser/components/ProjectAutomationsModal/ProjectAutomationsModal.test.tsx create mode 100644 src/browser/components/ProjectAutomationsModal/ProjectAutomationsModal.tsx create mode 100644 src/browser/components/WorkspaceActionsMenuContent/WorkspaceActionsMenuContent.test.tsx create mode 100644 src/browser/utils/projectWorkflowSchedules.ts create mode 100644 src/browser/utils/workflowScheduleIntervalMinutes.ts create mode 100644 src/common/utils/workflowSchedule.ts create mode 100644 src/common/utils/workflowScheduleTarget.test.ts create mode 100644 src/common/utils/workflowScheduleTarget.ts diff --git a/src/browser/components/AgentListItem/AgentListItem.stories.tsx b/src/browser/components/AgentListItem/AgentListItem.stories.tsx index 034e4b7403..e1d2d5bb72 100644 --- a/src/browser/components/AgentListItem/AgentListItem.stories.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.stories.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { useEffect } from "react"; import { AgentListItem } from "@/browser/components/AgentListItem/AgentListItem"; import { APIProvider } from "@/browser/contexts/API"; +import { ProjectProvider } from "@/browser/contexts/ProjectContext"; import { TelemetryEnabledProvider } from "@/browser/contexts/TelemetryEnabledContext"; import { TitleEditProvider } from "@/browser/contexts/WorkspaceTitleEditContext"; import { DndProvider } from "react-dnd"; @@ -181,17 +182,19 @@ function StoryScaffold(props: { return ( - - Promise.resolve({ success: true })}> - - -
-
{props.children}
-
-
-
-
-
+ + + Promise.resolve({ success: true })}> + + +
+
{props.children}
+
+
+
+
+
+
); } diff --git a/src/browser/components/AgentListItem/AgentListItem.test.tsx b/src/browser/components/AgentListItem/AgentListItem.test.tsx index 5e86ba6a0c..c23732ddb0 100644 --- a/src/browser/components/AgentListItem/AgentListItem.test.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.test.tsx @@ -7,6 +7,7 @@ import { installDom } from "../../../../tests/ui/dom"; import type * as ReactDndModuleType from "react-dnd"; import type * as ReactDndHtml5BackendModuleType from "react-dnd-html5-backend"; import type * as APIModuleType from "@/browser/contexts/API"; +import type * as ProjectContextModuleType from "@/browser/contexts/ProjectContext"; import type * as TelemetryEnabledContextModuleType from "@/browser/contexts/TelemetryEnabledContext"; import type * as WorkspaceTitleEditContextModuleType from "@/browser/contexts/WorkspaceTitleEditContext"; import type * as ContextMenuPositionModuleType from "@/browser/hooks/useContextMenuPosition"; @@ -133,6 +134,8 @@ function installAgentListItemTestDoubles() { const actualReactDndHtml5Backend = require("react-dnd-html5-backend?real=1") as typeof ReactDndHtml5BackendModuleType; const actualApi = require("@/browser/contexts/API?real=1") as typeof APIModuleType; + const actualProjectContext = + require("@/browser/contexts/ProjectContext?real=1") as typeof ProjectContextModuleType; const actualTelemetryEnabledContext = require("@/browser/contexts/TelemetryEnabledContext?real=1") as typeof TelemetryEnabledContextModuleType; const actualWorkspaceTitleEditContext = @@ -172,6 +175,14 @@ function installAgentListItemTestDoubles() { }), })); + void mock.module("@/browser/contexts/ProjectContext", () => ({ + ...actualProjectContext, + useProjectContext: () => ({ + getProjectConfig: () => undefined, + userProjects: new Map(), + }), + })); + void mock.module("@/browser/contexts/TelemetryEnabledContext", () => ({ ...actualTelemetryEnabledContext, useLinkSharingEnabled: () => false, @@ -193,6 +204,10 @@ function installAgentListItemTestDoubles() { WorkspaceHeartbeatModal: () => null, })); + void mock.module("../AutomationModal", () => ({ + AutomationModal: () => null, + })); + void mock.module("@/browser/hooks/useContextMenuPosition", () => ({ ...actualContextMenuPosition, useContextMenuPosition: () => ({ diff --git a/src/browser/components/AgentListItem/AgentListItem.tsx b/src/browser/components/AgentListItem/AgentListItem.tsx index 65ff0da825..229d862ee8 100644 --- a/src/browser/components/AgentListItem/AgentListItem.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.tsx @@ -73,8 +73,11 @@ import { useLinkSharingEnabled } from "@/browser/contexts/TelemetryEnabledContex import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { ShareTranscriptDialog } from "../ShareTranscriptDialog/ShareTranscriptDialog"; import { WorkspaceHeartbeatModal } from "../WorkspaceHeartbeatModal"; +import { AutomationModal } from "../AutomationModal"; import { WorkspaceActionsMenuContent } from "../WorkspaceActionsMenuContent/WorkspaceActionsMenuContent"; import { useAPI } from "@/browser/contexts/API"; +import { useProjectContext } from "@/browser/contexts/ProjectContext"; +import { getExistingWorkspaceProjectWorkflowSchedule } from "@/browser/utils/projectWorkflowSchedules"; export interface WorkspaceSelection { projectPath: string; @@ -432,6 +435,7 @@ function RegularAgentListItemInner(props: AgentListItemProps) { // Destructure metadata for convenience const { id: workspaceId, namedWorkspacePath } = metadata; + const dynamicWorkflowsEnabled = useExperimentValue(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS); const workspaceHeartbeatsEnabled = useExperimentValue(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS); const isInitializing = metadata.isInitializing === true; const isRemoving = isRemovingProp === true || metadata.isRemoving === true; @@ -485,9 +489,15 @@ function RegularAgentListItemInner(props: AgentListItemProps) { ? `${groupLabel} · ${workspaceTitle}` : workspaceTitle; const isEditing = editingWorkspaceId === workspaceId; + const { getProjectConfig } = useProjectContext(); + const projectWorkflowSchedule = getExistingWorkspaceProjectWorkflowSchedule({ + projectConfig: getProjectConfig(projectPath), + workspaceId, + }); const linkSharingEnabled = useLinkSharingEnabled(); const [shareTranscriptOpen, setShareTranscriptOpen] = useState(false); + const [automationModalOpen, setAutomationModalOpen] = useState(false); const [heartbeatModalOpen, setHeartbeatModalOpen] = useState(false); const overflowMenuButtonRef = useRef(null); const overflowMenuFrameRef = useRef(null); @@ -931,6 +941,9 @@ function RegularAgentListItemInner(props: AgentListItemProps) { onConfigureHeartbeat={ workspaceHeartbeatsEnabled ? () => setHeartbeatModalOpen(true) : null } + onConfigureAutomation={ + dynamicWorkflowsEnabled ? () => setAutomationModalOpen(true) : null + } onStopRuntime={ isRuntimeRunning && onStopRuntime ? () => @@ -996,6 +1009,16 @@ function RegularAgentListItemInner(props: AgentListItemProps) { /> + {dynamicWorkflowsEnabled && automationModalOpen && ( + + )} {workspaceHeartbeatsEnabled && ( Promise.resolve(WORKFLOW_DEFINITIONS), + }, + workspace: { + setWorkflowSchedule: () => Promise.resolve({ success: true as const, data: undefined }), + }, + projects: { + list: () => + Promise.resolve([ + [ + PROJECT_PATH, + { + workspaces: [{ id: WORKSPACE_ID, path: "/tmp/triage-control" }], + workflowSchedules: [WORKFLOW_SCHEDULE], + }, + ], + ]), + workflowSchedules: { + set: () => Promise.resolve({ success: true as const, data: WORKFLOW_SCHEDULE }), + remove: () => Promise.resolve({ success: true as const, data: undefined }), + }, + }, + } as unknown as APIClient; +} + +const AutomationStoryShell: FC<{ children: ReactNode }> = (props) => { + const clientRef = useRef(null); + clientRef.current ??= createAutomationClient(); + + return ( + + + {props.children} + + + ); +}; + +function renderAutomationModal(): JSX.Element { + return ( + + { + // Keep the story stable while users interact with the form. + }} + /> + + ); +} + +const meta: Meta = { + title: "Components/AutomationModal", + component: AutomationModal, + parameters: { + layout: "fullscreen", + chromatic: { delay: 500 }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: renderAutomationModal, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.ownerDocument.body); + await expect(canvas.findByText("Automation for Triage control")).resolves.toBeInTheDocument(); + await expect(canvas.findByLabelText("Automation workflow")).resolves.toBeInTheDocument(); + }, +}; + +export const Mobile: Story = { + render: renderAutomationModal, + globals: { viewport: { value: "mobile1", isRotated: false } }, + parameters: { + // Pinned mobile mode so Chromatic snapshots the responsive modal controls at phone width. + chromatic: { modes: { "dark-mobile": { theme: "dark", viewport: "mobile1", hasTouch: true } } }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.ownerDocument.body); + await expect(canvas.findByText("Automation for Triage control")).resolves.toBeInTheDocument(); + await expect(canvas.findByLabelText("Automation args")).resolves.toBeInTheDocument(); + }, +}; diff --git a/src/browser/components/AutomationModal/AutomationModal.test.tsx b/src/browser/components/AutomationModal/AutomationModal.test.tsx new file mode 100644 index 0000000000..8139e03d08 --- /dev/null +++ b/src/browser/components/AutomationModal/AutomationModal.test.tsx @@ -0,0 +1,446 @@ +import "../../../../tests/ui/dom"; + +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { installDom } from "../../../../tests/ui/dom"; +import * as APIModule from "@/browser/contexts/API"; +import type { APIClient, UseAPIResult } from "@/browser/contexts/API"; +import * as ProjectContextModule from "@/browser/contexts/ProjectContext"; +import type { ProjectWorkflowSchedule } from "@/common/types/project"; +import type { WorkflowDefinitionDescriptor } from "@/common/types/workflow"; + +void mock.module("@/browser/components/Dialog/Dialog", () => ({ + Dialog: (props: { open: boolean; children: ReactNode }) => + props.open ?
{props.children}
: null, + DialogContent: (props: { children: ReactNode; className?: string }) => ( +
{props.children}
+ ), + DialogHeader: (props: { children: ReactNode }) =>
{props.children}
, + DialogTitle: (props: { children: ReactNode; className?: string }) => ( +

{props.children}

+ ), +})); + +import { AutomationModal } from "./AutomationModal"; + +type ConnectedUseAPIResult = Extract; +type ErrorUseAPIResult = Extract; + +interface AutomationTestAPI { + workflows: { + listDefinitions: (input: { + workspaceId?: string; + projectPath?: string; + }) => Promise; + }; + projects: { + workflowSchedules: { + set: (input: { + projectPath: string; + schedule: Omit & { id?: string }; + }) => Promise< + { success: true; data: ProjectWorkflowSchedule } | { success: false; error: string } + >; + remove: (input: { + projectPath: string; + scheduleId: string; + }) => Promise<{ success: true; data: void } | { success: false; error: string }>; + }; + }; +} + +let cleanupDom: (() => void) | null = null; +let listDefinitionsMock: ReturnType; +let setProjectWorkflowScheduleMock: ReturnType; +let removeProjectWorkflowScheduleMock: ReturnType; +let refreshProjectsMock: ReturnType; + +function createConnectedUseAPIResult(api: AutomationTestAPI): ConnectedUseAPIResult { + return { + api: api as unknown as APIClient, + status: "connected", + error: null, + authenticate: () => undefined, + retry: () => undefined, + }; +} + +function createErrorUseAPIResult(): ErrorUseAPIResult { + return { + api: null, + status: "error", + error: "API unavailable", + authenticate: () => undefined, + retry: () => undefined, + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +function changeTextField(element: Element, value: string): void { + const formElement = element as HTMLInputElement | HTMLTextAreaElement; + const valueDescriptor = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(formElement), + "value" + ); + act(() => { + if (valueDescriptor?.set != null) { + valueDescriptor.set.call(formElement, value); + } + const trackedElement = formElement as unknown as { + _valueTracker?: { setValue: (trackedValue: string) => void }; + }; + trackedElement._valueTracker?.setValue(""); + fireEvent.input(formElement, { target: { value } }); + fireEvent.change(formElement, { target: { value } }); + }); +} + +function createWorkflowDefinition( + overrides: Partial = {} +): WorkflowDefinitionDescriptor { + return { + name: "triage-issues", + description: "Scan GitHub issues and create triage workspaces.", + scope: "project", + executable: true, + sourcePath: "/repo/.mux/workflows/triage-issues.js", + ...overrides, + }; +} + +function createProjectWorkflowSchedule( + overrides: Partial = {} +): ProjectWorkflowSchedule { + return { + id: "automation-1", + enabled: true, + workflowName: "triage-issues", + intervalMs: 15 * 60_000, + target: { type: "existing-workspace", workspaceId: "ws-1" }, + ...overrides, + }; +} + +function renderAutomationModal( + overrides: Partial> = {} +) { + return render( + undefined} + {...overrides} + /> + ); +} + +describe("AutomationModal", () => { + beforeEach(() => { + cleanupDom = installDom(); + listDefinitionsMock = mock(() => Promise.resolve([createWorkflowDefinition()])); + setProjectWorkflowScheduleMock = mock(() => + Promise.resolve({ success: true as const, data: createProjectWorkflowSchedule() }) + ); + removeProjectWorkflowScheduleMock = mock(() => + Promise.resolve({ success: true as const, data: undefined }) + ); + refreshProjectsMock = mock(() => Promise.resolve()); + const api: AutomationTestAPI = { + workflows: { + listDefinitions: listDefinitionsMock as AutomationTestAPI["workflows"]["listDefinitions"], + }, + projects: { + workflowSchedules: { + set: setProjectWorkflowScheduleMock as AutomationTestAPI["projects"]["workflowSchedules"]["set"], + remove: + removeProjectWorkflowScheduleMock as AutomationTestAPI["projects"]["workflowSchedules"]["remove"], + }, + }, + }; + spyOn(APIModule, "useAPI").mockImplementation(() => createConnectedUseAPIResult(api)); + spyOn(ProjectContextModule, "useProjectContext").mockImplementation( + () => + ({ + getProjectConfig: () => undefined, + refreshProjects: refreshProjectsMock, + }) as unknown as ReturnType + ); + }); + + afterEach(() => { + cleanup(); + mock.restore(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("preserves edits made before workflow definitions finish loading", async () => { + const definitions = createDeferred(); + listDefinitionsMock = mock(() => definitions.promise); + const view = renderAutomationModal(); + + fireEvent.click(view.getByRole("switch", { name: "Enable automation" })); + changeTextField(view.getByLabelText("Automation interval in minutes"), "45"); + changeTextField(view.getByLabelText("Automation args"), '{"label":"triage"}'); + + await act(async () => { + definitions.resolve([createWorkflowDefinition()]); + await definitions.promise; + }); + + await waitFor(() => { + expect((view.getByLabelText("Automation workflow") as HTMLSelectElement).value).toBe( + "triage-issues" + ); + }); + expect((view.getByLabelText("Automation interval in minutes") as HTMLInputElement).value).toBe( + "45" + ); + expect((view.getByLabelText("Automation args") as HTMLTextAreaElement).value).toBe( + '{"label":"triage"}' + ); + }); + + test("shows a loading option while workflow definitions are pending", () => { + listDefinitionsMock = mock(() => new Promise(() => undefined)); + const view = renderAutomationModal(); + + expect(view.getByRole("option", { name: "Loading workflows…" })).toBeTruthy(); + expect(view.queryByText("No executable workflows found")).toBeNull(); + }); + + test("loads workflow definitions by project path for sub-project schedules", async () => { + spyOn(ProjectContextModule, "useProjectContext").mockImplementation( + () => + ({ + getProjectConfig: () => ({ parentProjectPath: "/repo", workspaces: [] }), + refreshProjects: refreshProjectsMock, + }) as unknown as ReturnType + ); + renderAutomationModal({ projectPath: "/repo/packages/api" }); + + await waitFor(() => { + expect(listDefinitionsMock).toHaveBeenCalledWith({ projectPath: "/repo/packages/api" }); + }); + }); + + test("saves an enabled project workflow schedule for this workspace", async () => { + const onOpenChange = mock((_open: boolean) => undefined); + const view = renderAutomationModal({ onOpenChange }); + + await waitFor(() => { + expect((view.getByLabelText("Automation workflow") as HTMLSelectElement).value).toBe( + "triage-issues" + ); + }); + + fireEvent.click(view.getByRole("switch", { name: "Enable automation" })); + changeTextField(view.getByLabelText("Automation interval in minutes"), "30"); + changeTextField(view.getByLabelText("Automation args"), '{"label":"needs-triage"}'); + fireEvent.click(view.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(setProjectWorkflowScheduleMock).toHaveBeenCalledWith({ + projectPath: "/repo", + schedule: { + enabled: true, + workflowName: "triage-issues", + intervalMs: 30 * 60_000, + args: { label: "needs-triage" }, + target: { type: "existing-workspace", workspaceId: "ws-1" }, + }, + }); + }); + expect(refreshProjectsMock).toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + test("saves context mode for the workspace target", async () => { + const view = renderAutomationModal(); + + await waitFor(() => { + expect((view.getByLabelText("Automation workflow") as HTMLSelectElement).value).toBe( + "triage-issues" + ); + }); + + fireEvent.change(view.getByLabelText("Automation context mode"), { + target: { value: "compact" }, + }); + fireEvent.click(view.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(setProjectWorkflowScheduleMock).toHaveBeenCalledWith({ + projectPath: "/repo", + schedule: { + enabled: false, + workflowName: "triage-issues", + intervalMs: 15 * 60_000, + contextMode: "compact", + target: { type: "existing-workspace", workspaceId: "ws-1" }, + }, + }); + }); + expect(view.queryByLabelText("Automation run target")).toBeNull(); + }); + + test("loads and updates the project automation targeting this workspace", async () => { + listDefinitionsMock.mockImplementation(() => + Promise.resolve([ + createWorkflowDefinition(), + createWorkflowDefinition({ + name: "daily-maintenance", + description: "Run maintenance checks.", + }), + ]) + ); + const view = renderAutomationModal({ + projectWorkflowSchedule: createProjectWorkflowSchedule({ + id: "workspace-schedule", + title: "Named automation", + enabled: true, + workflowName: "daily-maintenance", + intervalMs: 60 * 60_000, + args: { cadence: "hourly" }, + }), + }); + + await waitFor(() => { + expect((view.getByLabelText("Automation workflow") as HTMLSelectElement).value).toBe( + "daily-maintenance" + ); + }); + expect((view.getByLabelText("Automation interval in minutes") as HTMLInputElement).value).toBe( + "60" + ); + expect((view.getByLabelText("Automation args") as HTMLTextAreaElement).value).toBe( + JSON.stringify({ cadence: "hourly" }, null, 2) + ); + + fireEvent.click(view.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(setProjectWorkflowScheduleMock).toHaveBeenCalledWith({ + projectPath: "/repo", + schedule: { + id: "workspace-schedule", + title: "Named automation", + enabled: true, + workflowName: "daily-maintenance", + intervalMs: 60 * 60_000, + args: { cadence: "hourly" }, + target: { type: "existing-workspace", workspaceId: "ws-1" }, + }, + }); + }); + }); + + test("shows persisted non-executable workflow selections", async () => { + listDefinitionsMock.mockImplementation(() => + Promise.resolve([ + createWorkflowDefinition({ + name: "blocked-workflow", + executable: false, + blockedReason: "project is untrusted", + }), + createWorkflowDefinition(), + ]) + ); + const view = renderAutomationModal({ + projectWorkflowSchedule: createProjectWorkflowSchedule({ + enabled: true, + workflowName: "blocked-workflow", + intervalMs: 15 * 60_000, + }), + }); + + await waitFor(() => { + expect( + view.getByRole("option", { name: "blocked-workflow (project is untrusted)" }) + ).toBeTruthy(); + }); + expect((view.getByLabelText("Automation workflow") as HTMLSelectElement).value).toBe( + "blocked-workflow" + ); + expect((view.getByRole("button", { name: "Save" }) as HTMLButtonElement).disabled).toBe(true); + }); + + test("blocks saving when args are not a JSON object", async () => { + const view = renderAutomationModal(); + + await waitFor(() => { + expect((view.getByLabelText("Automation workflow") as HTMLSelectElement).value).toBe( + "triage-issues" + ); + }); + + changeTextField(view.getByLabelText("Automation args"), "[]"); + + expect(view.getByText("Workflow args must be a JSON object.")).toBeTruthy(); + expect((view.getByRole("button", { name: "Save" }) as HTMLButtonElement).disabled).toBe(true); + }); + + test("removes the project automation targeting this workspace", async () => { + const view = renderAutomationModal({ + projectWorkflowSchedule: createProjectWorkflowSchedule({ id: "workspace-schedule" }), + }); + + fireEvent.click(view.getByRole("button", { name: "Remove automation" })); + + await waitFor(() => { + expect(removeProjectWorkflowScheduleMock).toHaveBeenCalledWith({ + projectPath: "/repo", + scheduleId: "workspace-schedule", + }); + }); + expect(refreshProjectsMock).toHaveBeenCalled(); + }); + + test("disables remove when the API client is unavailable", () => { + spyOn(APIModule, "useAPI").mockImplementation(() => createErrorUseAPIResult()); + const view = renderAutomationModal({ + projectWorkflowSchedule: createProjectWorkflowSchedule({ + enabled: false, + workflowName: "triage-issues", + intervalMs: 15 * 60_000, + }), + }); + + expect( + (view.getByRole("button", { name: "Remove automation" }) as HTMLButtonElement).disabled + ).toBe(true); + }); + + test("announces and associates interval and args validation errors", async () => { + const view = renderAutomationModal(); + + await waitFor(() => { + expect((view.getByLabelText("Automation workflow") as HTMLSelectElement).value).toBe( + "triage-issues" + ); + }); + + const intervalInput = view.getByLabelText("Automation interval in minutes") as HTMLInputElement; + const argsInput = view.getByLabelText("Automation args") as HTMLTextAreaElement; + changeTextField(intervalInput, "0"); + changeTextField(argsInput, "[]"); + + const alert = view.getByRole("alert"); + expect(alert.textContent).toContain("Schedule interval must be between 1 and 1440 minutes."); + expect(alert.textContent).toContain("Workflow args must be a JSON object."); + expect(intervalInput.getAttribute("aria-invalid")).toBe("true"); + expect(intervalInput.getAttribute("aria-describedby")).toContain("automation-interval-error"); + expect(argsInput.getAttribute("aria-invalid")).toBe("true"); + expect(argsInput.getAttribute("aria-describedby")).toContain("automation-args-error"); + }); +}); diff --git a/src/browser/components/AutomationModal/AutomationModal.tsx b/src/browser/components/AutomationModal/AutomationModal.tsx new file mode 100644 index 0000000000..0cf5501f5f --- /dev/null +++ b/src/browser/components/AutomationModal/AutomationModal.tsx @@ -0,0 +1,595 @@ +import React, { useEffect, useRef, useState } from "react"; +import { CalendarClock, Loader2 } from "lucide-react"; +import { Button } from "@/browser/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/browser/components/Dialog/Dialog"; +import { Input } from "@/browser/components/Input/Input"; +import { Switch } from "@/browser/components/Switch/Switch"; +import { useAPI } from "@/browser/contexts/API"; +import { useProjectContext } from "@/browser/contexts/ProjectContext"; +import type { ProjectWorkflowSchedule } from "@/common/types/project"; +import type { WorkflowDefinitionDescriptor } from "@/common/types/workflow"; +import { getErrorMessage } from "@/common/utils/errors"; +import assert from "@/common/utils/assert"; +import { + WORKFLOW_SCHEDULE_DEFAULT_CONTEXT_MODE, + WORKFLOW_SCHEDULE_DEFAULT_INTERVAL_MS, + type WorkflowScheduleContextMode, +} from "@/constants/workflowSchedule"; +import { + clampWorkflowScheduleIntervalMinutes, + formatWorkflowArgs, + formatWorkflowScheduleIntervalMinutes, + getWorkflowScheduleIntervalValidationError, + parseWorkflowArgs, + parseWorkflowScheduleIntervalMinutes, + WORKFLOW_SCHEDULE_DEFAULT_INTERVAL_MINUTES, + WORKFLOW_SCHEDULE_MAX_INTERVAL_MINUTES, + WORKFLOW_SCHEDULE_MIN_INTERVAL_MINUTES, + workflowScheduleIntervalMinutesToMs, +} from "@/browser/utils/workflowScheduleIntervalMinutes"; + +interface AutomationModalProps { + open: boolean; + projectPath: string; + workspaceId: string; + workspaceName: string; + projectWorkflowSchedule?: ProjectWorkflowSchedule; + onOpenChange: (open: boolean) => void; +} + +function getWorkspaceLabel(workspaceName: string): string { + const trimmedName = workspaceName.trim(); + return trimmedName.length > 0 ? trimmedName : "this workspace"; +} + +function formatLastRunStartedAt(value: string | undefined): string | null { + if (!value) { + return null; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + + return date.toLocaleString(); +} + +type AutomationDraft = Pick< + ProjectWorkflowSchedule, + "enabled" | "workflowName" | "intervalMs" | "args" | "contextMode" | "lastRunStartedAt" +>; + +export function AutomationModal(props: AutomationModalProps) { + const { api } = useAPI(); + const { getProjectConfig, refreshProjects } = useProjectContext(); + const workflowSchedule: AutomationDraft | undefined = props.projectWorkflowSchedule; + const projectConfig = getProjectConfig(props.projectPath); + const shouldLoadDefinitionsByProject = projectConfig?.parentProjectPath != null; + const initializationKey = JSON.stringify({ + projectPath: props.projectPath, + workspaceId: props.workspaceId, + projectScheduleId: props.projectWorkflowSchedule?.id ?? "", + enabled: workflowSchedule?.enabled ?? false, + workflowName: workflowSchedule?.workflowName ?? "", + intervalMs: workflowSchedule?.intervalMs ?? WORKFLOW_SCHEDULE_DEFAULT_INTERVAL_MS, + args: workflowSchedule?.args ?? null, + contextMode: workflowSchedule?.contextMode ?? WORKFLOW_SCHEDULE_DEFAULT_CONTEXT_MODE, + }); + const [workflowDefinitions, setWorkflowDefinitions] = useState( + [] + ); + const [definitionsLoading, setDefinitionsLoading] = useState(false); + const [definitionsError, setDefinitionsError] = useState(null); + const [draftEnabled, setDraftEnabled] = useState(() => workflowSchedule?.enabled ?? false); + const [draftWorkflowName, setDraftWorkflowName] = useState( + () => workflowSchedule?.workflowName ?? "" + ); + const [draftIntervalMinutes, setDraftIntervalMinutes] = useState(() => + formatWorkflowScheduleIntervalMinutes( + workflowSchedule?.intervalMs ?? WORKFLOW_SCHEDULE_DEFAULT_INTERVAL_MS + ) + ); + const [draftArgs, setDraftArgs] = useState(() => formatWorkflowArgs(workflowSchedule?.args)); + const [draftContextMode, setDraftContextMode] = useState( + () => workflowSchedule?.contextMode ?? WORKFLOW_SCHEDULE_DEFAULT_CONTEXT_MODE + ); + const [workflowNameTouched, setWorkflowNameTouched] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [definitionsLoaded, setDefinitionsLoaded] = useState(false); + const lastInitializedKeyRef = useRef(props.open ? initializationKey : null); + + const executableWorkflowDefinitions = workflowDefinitions.filter( + (workflow) => workflow.executable + ); + const firstExecutableWorkflowName = executableWorkflowDefinitions[0]?.name ?? ""; + const selectedWorkflowDefinition = workflowDefinitions.find( + (workflow) => workflow.name === draftWorkflowName + ); + const selectedWorkflowExecutable = selectedWorkflowDefinition?.executable === true; + const selectedWorkflowMissing = + draftWorkflowName.length > 0 && selectedWorkflowDefinition == null; + const selectedWorkflowNonExecutable = + draftWorkflowName.length > 0 && + selectedWorkflowDefinition != null && + !selectedWorkflowExecutable; + const definitionsPending = + definitionsLoading || + (props.open && api != null && props.workspaceId.length > 0 && !definitionsLoaded); + const argsParseResult = parseWorkflowArgs(draftArgs); + const intervalValidationError = getWorkflowScheduleIntervalValidationError(draftIntervalMinutes); + const intervalHelpId = "automation-interval-help"; + const intervalErrorId = "automation-interval-error"; + const argsHelpId = "automation-args-help"; + const argsErrorId = "automation-args-error"; + const intervalDescriptionIds = [intervalHelpId, intervalValidationError ? intervalErrorId : null] + .filter((id): id is string => id != null) + .join(" "); + const argsDescriptionIds = [argsHelpId, argsParseResult.error ? argsErrorId : null] + .filter((id): id is string => id != null) + .join(" "); + const workflowValidationError = definitionsPending + ? null + : draftWorkflowName.length === 0 + ? "Choose a workflow before saving." + : draftEnabled && !selectedWorkflowExecutable + ? "Choose an executable workflow before enabling the automation." + : null; + const errorMessages = [ + definitionsError, + saveError, + intervalValidationError, + argsParseResult.error, + workflowValidationError, + ].filter((message): message is string => message != null); + const hasSchedule = workflowSchedule != null; + const workspaceLabel = getWorkspaceLabel(props.workspaceName); + const lastRunStartedAt = formatLastRunStartedAt(workflowSchedule?.lastRunStartedAt); + const hasBlockingError = + isSaving || + api == null || + definitionsPending || + intervalValidationError != null || + argsParseResult.error != null || + workflowValidationError != null; + + useEffect(() => { + if (!props.open) { + lastInitializedKeyRef.current = null; + return; + } + + if (lastInitializedKeyRef.current === initializationKey) { + return; + } + + lastInitializedKeyRef.current = initializationKey; + setDraftEnabled(workflowSchedule?.enabled ?? false); + setDraftWorkflowName(workflowSchedule?.workflowName ?? ""); + setDraftIntervalMinutes( + formatWorkflowScheduleIntervalMinutes( + workflowSchedule?.intervalMs ?? WORKFLOW_SCHEDULE_DEFAULT_INTERVAL_MS + ) + ); + setDraftArgs(formatWorkflowArgs(workflowSchedule?.args)); + setDraftContextMode(workflowSchedule?.contextMode ?? WORKFLOW_SCHEDULE_DEFAULT_CONTEXT_MODE); + setWorkflowNameTouched(false); + setSaveError(null); + }, [initializationKey, props.open, props.workspaceId, workflowSchedule]); + + useEffect(() => { + if ( + !props.open || + workflowNameTouched || + draftWorkflowName.length > 0 || + workflowSchedule?.workflowName + ) { + return; + } + + if (firstExecutableWorkflowName.length > 0) { + setDraftWorkflowName(firstExecutableWorkflowName); + } + }, [ + draftWorkflowName, + firstExecutableWorkflowName, + props.open, + workflowSchedule?.workflowName, + workflowNameTouched, + ]); + + useEffect(() => { + if ( + !props.open || + !api || + (shouldLoadDefinitionsByProject + ? props.projectPath.length === 0 + : props.workspaceId.length === 0) + ) { + setWorkflowDefinitions([]); + setDefinitionsLoading(false); + setDefinitionsLoaded(false); + setDefinitionsError(null); + return; + } + + let ignore = false; + setDefinitionsLoading(true); + setDefinitionsLoaded(false); + setDefinitionsError(null); + + void (async () => { + try { + const definitions = await api.workflows.listDefinitions( + shouldLoadDefinitionsByProject + ? { projectPath: props.projectPath } + : { workspaceId: props.workspaceId } + ); + if (ignore) { + return; + } + setWorkflowDefinitions(Array.isArray(definitions) ? definitions : []); + setDefinitionsLoaded(true); + } catch (error) { + if (ignore) { + return; + } + setWorkflowDefinitions([]); + setDefinitionsLoaded(true); + setDefinitionsError(getErrorMessage(error) || "Failed to load workflow definitions."); + } finally { + if (!ignore) { + setDefinitionsLoading(false); + } + } + })(); + + return () => { + ignore = true; + }; + }, [api, props.open, props.projectPath, props.workspaceId, shouldLoadDefinitionsByProject]); + + const handleIntervalBlur = () => { + const parsedMinutes = parseWorkflowScheduleIntervalMinutes(draftIntervalMinutes); + if (parsedMinutes == null) { + return; + } + + const clampedMinutes = clampWorkflowScheduleIntervalMinutes(parsedMinutes); + const clampedMinutesValue = String(clampedMinutes); + if (clampedMinutesValue !== draftIntervalMinutes) { + setDraftIntervalMinutes(clampedMinutesValue); + } + }; + + const saveSchedule = async ( + nextSchedule: Omit + ): Promise => { + setIsSaving(true); + setSaveError(null); + if (api == null) { + setSaveError("Automation settings are unavailable while disconnected."); + setIsSaving(false); + return false; + } + if (props.projectPath.trim().length === 0 || props.workspaceId.trim().length === 0) { + setSaveError("Automation settings require a project and workspace."); + setIsSaving(false); + return false; + } + + try { + const result = await api.projects.workflowSchedules.set({ + projectPath: props.projectPath, + schedule: { + ...(props.projectWorkflowSchedule != null + ? { + id: props.projectWorkflowSchedule.id, + ...(props.projectWorkflowSchedule.title != null + ? { title: props.projectWorkflowSchedule.title } + : {}), + } + : {}), + ...nextSchedule, + }, + }); + if (!result.success) { + setSaveError(result.error ?? "Failed to save automation."); + return false; + } + + await refreshProjects(); + return true; + } catch (error) { + setSaveError(getErrorMessage(error) || "Failed to save automation."); + return false; + } finally { + setIsSaving(false); + } + }; + + const handleSave = async () => { + const parsedMinutes = parseWorkflowScheduleIntervalMinutes(draftIntervalMinutes); + assert(parsedMinutes != null, "Save should only run with a valid workflow schedule interval"); + assert( + parsedMinutes >= WORKFLOW_SCHEDULE_MIN_INTERVAL_MINUTES && + parsedMinutes <= WORKFLOW_SCHEDULE_MAX_INTERVAL_MINUTES, + "Save should only run with a workflow schedule interval inside the supported range" + ); + assert(argsParseResult.error == null, "Save should only run with valid workflow args JSON"); + assert(draftWorkflowName.length > 0, "Save should only run with a selected workflow"); + assert( + !draftEnabled || selectedWorkflowExecutable, + "Enabled schedules must reference an executable workflow" + ); + + const didSave = await saveSchedule({ + enabled: draftEnabled, + workflowName: draftWorkflowName, + intervalMs: workflowScheduleIntervalMinutesToMs(parsedMinutes), + ...(argsParseResult.args ? { args: argsParseResult.args } : {}), + ...(draftContextMode !== WORKFLOW_SCHEDULE_DEFAULT_CONTEXT_MODE + ? { contextMode: draftContextMode } + : {}), + target: { type: "existing-workspace", workspaceId: props.workspaceId }, + }); + if (didSave) { + props.onOpenChange(false); + } + }; + + const handleRemove = async () => { + setIsSaving(true); + setSaveError(null); + if (api == null) { + setSaveError("Automation settings are unavailable while disconnected."); + setIsSaving(false); + return; + } + + try { + if (props.projectWorkflowSchedule != null) { + const result = await api.projects.workflowSchedules.remove({ + projectPath: props.projectPath, + scheduleId: props.projectWorkflowSchedule.id, + }); + if (!result.success) { + throw new Error(result.error ?? "Failed to remove automation."); + } + } + await refreshProjects(); + props.onOpenChange(false); + } catch (error) { + setSaveError(getErrorMessage(error) || "Failed to remove automation."); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + + + Automation for {workspaceLabel} + + + +
+

+ Configure the project-level automation that runs in this workspace. Each workspace can + be targeted by only one automation. +

+ +
+
+
+
Enable automation
+
+ Keep this automation eligible for recurring background workflow runs. +
+
+ { + setDraftEnabled(checked); + }} + disabled={isSaving} + aria-label="Enable automation" + /> +
+ +
+ +
+ + {definitionsPending && ( + + )} +
+
+ +
+ + +
+ +
+ +
+ ) => { + setDraftIntervalMinutes(event.currentTarget.value); + }} + onBlur={handleIntervalBlur} + disabled={isSaving} + className="border-border-medium bg-background-secondary h-9 w-24 text-right" + aria-label="Automation interval in minutes" + aria-invalid={intervalValidationError != null} + aria-describedby={intervalDescriptionIds} + /> + min +
+
+ +
+ +