diff --git a/src-tauri/crates/berdctl/api-surface-feedback.json b/src-tauri/crates/berdctl/api-surface-feedback.json index e3e404ac5..24aaa7e2e 100644 --- a/src-tauri/crates/berdctl/api-surface-feedback.json +++ b/src-tauri/crates/berdctl/api-surface-feedback.json @@ -799,6 +799,132 @@ } } }, + "schedules": { + "description": "Manage scheduled recipe jobs in Berd's live embedded Goose scheduler: list, pause, unpause, kill an active run, or remove a schedule.", + "actions": { + "list": { + "description": "List scheduled recipe jobs from the live Goose scheduler embedded in Berd. Unlike a separate goose schedule process, this reads the scheduler instance that is actually running the jobs and does not change app state.", + "fields": [], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "pause": { + "description": "Pause an idle scheduled recipe job through the live Goose scheduler embedded in Berd. Future scheduled runs stop until the job is unpaused. If a run is active, stop it first with berdctl schedule kill.", + "fields": [ + { + "name": "schedule_id", + "required": true, + "kind": "string", + "description": "Id of the scheduled recipe job to pause.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schedule_id": { + "type": "string", + "minLength": 1, + "description": "Id of the scheduled recipe job to pause." + } + }, + "required": ["schedule_id"], + "additionalProperties": false + } + }, + "unpause": { + "description": "Unpause a scheduled recipe job through the live Goose scheduler embedded in Berd. Future runs resume on the job's existing cron schedule.", + "fields": [ + { + "name": "schedule_id", + "required": true, + "kind": "string", + "description": "Id of the scheduled recipe job to unpause.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schedule_id": { + "type": "string", + "minLength": 1, + "description": "Id of the scheduled recipe job to unpause." + } + }, + "required": ["schedule_id"], + "additionalProperties": false + } + }, + "kill": { + "description": "Stop the current run of a scheduled recipe job through Berd's live Goose scheduler. The schedule itself remains registered and can run again later; use remove or pause when future runs must also stop.", + "fields": [ + { + "name": "schedule_id", + "required": true, + "kind": "string", + "description": "Id of the scheduled recipe job whose active run should stop.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schedule_id": { + "type": "string", + "minLength": 1, + "description": "Id of the scheduled recipe job whose active run should stop." + } + }, + "required": ["schedule_id"], + "additionalProperties": false + } + }, + "remove": { + "description": "Remove a scheduled recipe job through the live Goose scheduler embedded in Berd. This updates the scheduler that is actually running jobs, so a stale in-memory copy cannot restore the deleted schedule. The saved recipe file is kept.", + "fields": [ + { + "name": "schedule_id", + "required": true, + "kind": "string", + "description": "Id of the scheduled recipe job to remove.", + "min": 1 + }, + { + "name": "confirm", + "required": false, + "kind": "boolean", + "description": "Confirm removal of the schedule registration. The saved recipe file is kept." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schedule_id": { + "type": "string", + "minLength": 1, + "description": "Id of the scheduled recipe job to remove." + }, + "confirm": { + "default": false, + "description": "Confirm removal of the schedule registration. The saved recipe file is kept.", + "type": "boolean" + } + }, + "required": ["schedule_id"], + "additionalProperties": false + } + } + } + }, "agents": { "description": "Manage the user's agents (personas): create, list.", "actions": { diff --git a/src-tauri/crates/berdctl/api-surface.json b/src-tauri/crates/berdctl/api-surface.json index 18c5163b0..f10c2645b 100644 --- a/src-tauri/crates/berdctl/api-surface.json +++ b/src-tauri/crates/berdctl/api-surface.json @@ -799,6 +799,132 @@ } } }, + "schedules": { + "description": "Manage scheduled recipe jobs in Berd's live embedded Goose scheduler: list, pause, unpause, kill an active run, or remove a schedule.", + "actions": { + "list": { + "description": "List scheduled recipe jobs from the live Goose scheduler embedded in Berd. Unlike a separate goose schedule process, this reads the scheduler instance that is actually running the jobs and does not change app state.", + "fields": [], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "pause": { + "description": "Pause an idle scheduled recipe job through the live Goose scheduler embedded in Berd. Future scheduled runs stop until the job is unpaused. If a run is active, stop it first with berdctl schedule kill.", + "fields": [ + { + "name": "schedule_id", + "required": true, + "kind": "string", + "description": "Id of the scheduled recipe job to pause.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schedule_id": { + "type": "string", + "minLength": 1, + "description": "Id of the scheduled recipe job to pause." + } + }, + "required": ["schedule_id"], + "additionalProperties": false + } + }, + "unpause": { + "description": "Unpause a scheduled recipe job through the live Goose scheduler embedded in Berd. Future runs resume on the job's existing cron schedule.", + "fields": [ + { + "name": "schedule_id", + "required": true, + "kind": "string", + "description": "Id of the scheduled recipe job to unpause.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schedule_id": { + "type": "string", + "minLength": 1, + "description": "Id of the scheduled recipe job to unpause." + } + }, + "required": ["schedule_id"], + "additionalProperties": false + } + }, + "kill": { + "description": "Stop the current run of a scheduled recipe job through Berd's live Goose scheduler. The schedule itself remains registered and can run again later; use remove or pause when future runs must also stop.", + "fields": [ + { + "name": "schedule_id", + "required": true, + "kind": "string", + "description": "Id of the scheduled recipe job whose active run should stop.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schedule_id": { + "type": "string", + "minLength": 1, + "description": "Id of the scheduled recipe job whose active run should stop." + } + }, + "required": ["schedule_id"], + "additionalProperties": false + } + }, + "remove": { + "description": "Remove a scheduled recipe job through the live Goose scheduler embedded in Berd. This updates the scheduler that is actually running jobs, so a stale in-memory copy cannot restore the deleted schedule. The saved recipe file is kept.", + "fields": [ + { + "name": "schedule_id", + "required": true, + "kind": "string", + "description": "Id of the scheduled recipe job to remove.", + "min": 1 + }, + { + "name": "confirm", + "required": false, + "kind": "boolean", + "description": "Confirm removal of the schedule registration. The saved recipe file is kept." + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schedule_id": { + "type": "string", + "minLength": 1, + "description": "Id of the scheduled recipe job to remove." + }, + "confirm": { + "default": false, + "description": "Confirm removal of the schedule registration. The saved recipe file is kept.", + "type": "boolean" + } + }, + "required": ["schedule_id"], + "additionalProperties": false + } + } + } + }, "agents": { "description": "Manage the user's agents (personas): create, list.", "actions": { diff --git a/src-tauri/crates/berdctl/cli-surface-feedback.json b/src-tauri/crates/berdctl/cli-surface-feedback.json index 1bd8962b5..d4ceb7f0f 100644 --- a/src-tauri/crates/berdctl/cli-surface-feedback.json +++ b/src-tauri/crates/berdctl/cli-surface-feedback.json @@ -124,6 +124,37 @@ } } }, + "schedule": { + "group": "schedules", + "about": "Manage jobs in Berd's live Goose scheduler: list, pause, unpause, kill, remove", + "verbs": { + "list": { + "action": "list", + "about": "List scheduled recipe jobs from Berd's live Goose scheduler", + "afterHelp": "Example:\n berdctl schedule list --json\n\nResult:\n {\"schedules\": [{\"id\": \"daily-report\", \"cron\": \"0 9 * * *\",\n \"paused\": false, \"currently_running\": false,\n \"source\": \"...\", \"last_run\": null,\n \"current_session_id\": null, \"job_start_time\": null}]}" + }, + "pause": { + "action": "pause", + "about": "Pause a job in Berd's live Goose scheduler", + "afterHelp": "Example:\n berdctl schedule pause --schedule-id daily-report\n\nResult:\n {\"ok\": true, \"schedule_id\": \"daily-report\", \"paused\": true}" + }, + "unpause": { + "action": "unpause", + "about": "Resume a paused job in Berd's live Goose scheduler", + "afterHelp": "Example:\n berdctl schedule unpause --schedule-id daily-report\n\nResult:\n {\"ok\": true, \"schedule_id\": \"daily-report\", \"paused\": false}" + }, + "kill": { + "action": "kill", + "about": "Stop the active run of a scheduled recipe job", + "afterHelp": "Examples:\n berdctl schedule kill --schedule-id daily-report\n berdctl schedule kill --schedule-id daily-report && \\\n berdctl schedule remove --schedule-id daily-report --confirm\n\nResult:\n {\"ok\": true, \"schedule_id\": \"daily-report\", \"message\": \"...\"}" + }, + "remove": { + "action": "remove", + "about": "Remove a job from Berd's live Goose scheduler", + "afterHelp": "Example:\n berdctl schedule remove --schedule-id daily-report --confirm\n\nResult:\n {\"ok\": true, \"schedule_id\": \"daily-report\"}\n\nIf the job is currently running, use berdctl schedule kill first when you also need to stop that active run." + } + } + }, "agent": { "group": "agents", "about": "Manage agents (personas): create, list", diff --git a/src-tauri/crates/berdctl/cli-surface.json b/src-tauri/crates/berdctl/cli-surface.json index c15c55bbe..e2cc17b4e 100644 --- a/src-tauri/crates/berdctl/cli-surface.json +++ b/src-tauri/crates/berdctl/cli-surface.json @@ -124,6 +124,37 @@ } } }, + "schedule": { + "group": "schedules", + "about": "Manage jobs in Berd's live Goose scheduler: list, pause, unpause, kill, remove", + "verbs": { + "list": { + "action": "list", + "about": "List scheduled recipe jobs from Berd's live Goose scheduler", + "afterHelp": "Example:\n berdctl schedule list --json\n\nResult:\n {\"schedules\": [{\"id\": \"daily-report\", \"cron\": \"0 9 * * *\",\n \"paused\": false, \"currently_running\": false,\n \"source\": \"...\", \"last_run\": null,\n \"current_session_id\": null, \"job_start_time\": null}]}" + }, + "pause": { + "action": "pause", + "about": "Pause a job in Berd's live Goose scheduler", + "afterHelp": "Example:\n berdctl schedule pause --schedule-id daily-report\n\nResult:\n {\"ok\": true, \"schedule_id\": \"daily-report\", \"paused\": true}" + }, + "unpause": { + "action": "unpause", + "about": "Resume a paused job in Berd's live Goose scheduler", + "afterHelp": "Example:\n berdctl schedule unpause --schedule-id daily-report\n\nResult:\n {\"ok\": true, \"schedule_id\": \"daily-report\", \"paused\": false}" + }, + "kill": { + "action": "kill", + "about": "Stop the active run of a scheduled recipe job", + "afterHelp": "Examples:\n berdctl schedule kill --schedule-id daily-report\n berdctl schedule kill --schedule-id daily-report && \\\n berdctl schedule remove --schedule-id daily-report --confirm\n\nResult:\n {\"ok\": true, \"schedule_id\": \"daily-report\", \"message\": \"...\"}" + }, + "remove": { + "action": "remove", + "about": "Remove a job from Berd's live Goose scheduler", + "afterHelp": "Example:\n berdctl schedule remove --schedule-id daily-report --confirm\n\nResult:\n {\"ok\": true, \"schedule_id\": \"daily-report\"}\n\nIf the job is currently running, use berdctl schedule kill first when you also need to stop that active run." + } + } + }, "agent": { "group": "agents", "about": "Manage agents (personas): create, list", diff --git a/src-tauri/crates/berdctl/src/main.rs b/src-tauri/crates/berdctl/src/main.rs index 5cc943846..46d6c6dd4 100644 --- a/src-tauri/crates/berdctl/src/main.rs +++ b/src-tauri/crates/berdctl/src/main.rs @@ -297,6 +297,11 @@ mod tests { vec!["--project-id", "p", "--mode", "worktree"] } ("project", "archive") => vec!["--project-id", "p"], + ("schedule", "list") => vec![], + ("schedule", "pause") | ("schedule", "unpause") | ("schedule", "kill") => { + vec!["--schedule-id", "s"] + } + ("schedule", "remove") => vec!["--schedule-id", "s", "--confirm"], ("agent", "create") => vec!["--name", "n", "--system-prompt", "sp"], ("agent", "list") => vec![], ("skill", "create") => { diff --git a/src-tauri/crates/berdctl/src/tree.rs b/src-tauri/crates/berdctl/src/tree.rs index 0c1ca3f2b..057df7d64 100644 --- a/src-tauri/crates/berdctl/src/tree.rs +++ b/src-tauri/crates/berdctl/src/tree.rs @@ -26,6 +26,7 @@ sees there: move-to-group, send, clear-project, fork, archive project projects create, list, get, set-startup-mode, archive + schedule scheduled jobs list, pause, unpause, kill, remove agent agents (personas) create, list skill skills (SKILL.md) create, list, get info read-only lookups harnesses, models, context @@ -67,7 +68,7 @@ pub fn build_cli(contract: &Contract) -> Command { let mut cmd = Command::new("berdctl") .bin_name("berdctl") .term_width(HELP_WIDTH) - .about("Control the Berd desktop app (sessions, projects, agents, skills) from the command line") + .about("Control the Berd desktop app (sessions, projects, schedules, agents, skills) from the command line") .long_about(TOP_LEVEL_LONG_ABOUT) .after_long_help(TOP_LEVEL_EXAMPLES) .subcommand_required(true) diff --git a/src/features/automations/api/localSchedules.test.ts b/src/features/automations/api/localSchedules.test.ts new file mode 100644 index 000000000..bdff1346d --- /dev/null +++ b/src/features/automations/api/localSchedules.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getClient } from "@/shared/api/acpConnection"; +import { removeLocalSchedule } from "./localSchedules"; + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + pause: vi.fn(), + unpause: vi.fn(), + remove: vi.fn(), +})); + +vi.mock("@/shared/api/acpConnection", () => ({ + getClient: vi.fn(async () => ({ + goose: { + GooseUnstableSchedulesList: mocks.list, + GooseUnstableSchedulesPause: mocks.pause, + GooseUnstableSchedulesUnpause: mocks.unpause, + GooseUnstableSchedulesDelete: mocks.remove, + }, + })), +})); + +describe("removeLocalSchedule", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.list.mockResolvedValue({ + jobs: [{ id: "daily-report", paused: false, currentlyRunning: false }], + }); + mocks.pause.mockResolvedValue(undefined); + mocks.unpause.mockResolvedValue(undefined); + mocks.remove.mockResolvedValue(undefined); + }); + + it("pauses an idle schedule before removing it", async () => { + await removeLocalSchedule("daily-report"); + + expect(getClient).toHaveBeenCalledOnce(); + expect(mocks.pause).toHaveBeenCalledWith({ scheduleId: "daily-report" }); + expect(mocks.remove).toHaveBeenCalledWith({ scheduleId: "daily-report" }); + expect(mocks.unpause).not.toHaveBeenCalled(); + }); + + it("restores the prior state when removal fails", async () => { + mocks.remove.mockRejectedValue(new Error("delete failed")); + + await expect(removeLocalSchedule("daily-report")).rejects.toThrow( + "delete failed", + ); + expect(mocks.unpause).toHaveBeenCalledWith({ scheduleId: "daily-report" }); + }); + + it("does not alter an already-paused schedule when removal fails", async () => { + mocks.list.mockResolvedValue({ + jobs: [{ id: "daily-report", paused: true, currentlyRunning: false }], + }); + mocks.remove.mockRejectedValue(new Error("delete failed")); + + await expect(removeLocalSchedule("daily-report")).rejects.toThrow( + "delete failed", + ); + expect(mocks.pause).not.toHaveBeenCalled(); + expect(mocks.unpause).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/automations/api/localSchedules.ts b/src/features/automations/api/localSchedules.ts new file mode 100644 index 000000000..a290267ce --- /dev/null +++ b/src/features/automations/api/localSchedules.ts @@ -0,0 +1,50 @@ +import { getClient } from "@/shared/api/acpConnection"; +import type { ScheduledJobDto } from "@aaif/goose-sdk"; + +export const LOCAL_SCHEDULES_QUERY_KEY = ["local-goose-schedules"] as const; + +export async function listLocalSchedules(): Promise { + const client = await getClient(); + const response = await client.goose.GooseUnstableSchedulesList({}); + return response.jobs; +} + +export async function pauseLocalSchedule(scheduleId: string): Promise { + const client = await getClient(); + await client.goose.GooseUnstableSchedulesPause({ scheduleId }); +} + +export async function unpauseLocalSchedule(scheduleId: string): Promise { + const client = await getClient(); + await client.goose.GooseUnstableSchedulesUnpause({ scheduleId }); +} + +export async function removeLocalSchedule(scheduleId: string): Promise { + const client = await getClient(); + const { jobs } = await client.goose.GooseUnstableSchedulesList({}); + const schedule = jobs.find((job) => job.id === scheduleId); + if (schedule?.currentlyRunning) { + throw new Error( + `Schedule "${scheduleId}" is still running. Stop the active run before removing it.`, + ); + } + const pausedForRemoval = schedule != null && !schedule.paused; + if (pausedForRemoval) { + await client.goose.GooseUnstableSchedulesPause({ scheduleId }); + } + try { + await client.goose.GooseUnstableSchedulesDelete({ scheduleId }); + } catch (error) { + if (pausedForRemoval) { + await client.goose.GooseUnstableSchedulesUnpause({ scheduleId }); + } + throw error; + } +} + +export async function killLocalScheduleRun(scheduleId: string): Promise { + const client = await getClient(); + await client.goose.GooseUnstableSchedulesRunningJobKill({ + jobId: scheduleId, + }); +} diff --git a/src/features/automations/ui/AutomationsView.test.tsx b/src/features/automations/ui/AutomationsView.test.tsx index b3ac5ad22..3c417e098 100644 --- a/src/features/automations/ui/AutomationsView.test.tsx +++ b/src/features/automations/ui/AutomationsView.test.tsx @@ -12,6 +12,12 @@ import { refreshAutomationTile, updateAutomationTile, } from "@/features/automations/api/kgooseAutomations"; +import { + listLocalSchedules, + killLocalScheduleRun, + pauseLocalSchedule, + removeLocalSchedule, +} from "@/features/automations/api/localSchedules"; import { TopBarActionsProvider, useTopBarActions, @@ -44,6 +50,15 @@ vi.mock("@/features/automations/api/kgooseAutomations", () => ({ refreshAutomationTile: vi.fn(), })); +vi.mock("@/features/automations/api/localSchedules", () => ({ + LOCAL_SCHEDULES_QUERY_KEY: ["local-goose-schedules"], + listLocalSchedules: vi.fn(), + pauseLocalSchedule: vi.fn(), + unpauseLocalSchedule: vi.fn(), + removeLocalSchedule: vi.fn(), + killLocalScheduleRun: vi.fn(), +})); + vi.mock("@/features/automations/ui/AutomationBuilderView", () => ({ AutomationBuilderView: ({ onClose, @@ -108,6 +123,10 @@ describe("AutomationsView", () => { beforeEach(() => { resetHomeWidgetStoreForTests(); vi.clearAllMocks(); + vi.mocked(listLocalSchedules).mockResolvedValue([]); + vi.mocked(pauseLocalSchedule).mockResolvedValue(undefined); + vi.mocked(removeLocalSchedule).mockResolvedValue(undefined); + vi.mocked(killLocalScheduleRun).mockResolvedValue(undefined); vi.mocked(getAutomationTiles).mockResolvedValue({ tiles: [ { @@ -236,6 +255,70 @@ describe("AutomationsView", () => { expect(screen.getByText("Revenue was up.")).toBeInTheDocument(); }); + it("shows and controls local Goose schedules through the live scheduler", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(listLocalSchedules).mockResolvedValue([ + { + id: "daily-report", + source: "/recipes/daily-report.yaml", + cron: "0 9 * * *", + lastRun: null, + currentlyRunning: false, + paused: false, + currentSessionId: null, + jobStartTime: null, + }, + ]); + + renderAutomationsView(); + + expect(await screen.findByText("Local schedules")).toBeInTheDocument(); + expect(screen.getByText("daily-report")).toBeInTheDocument(); + expect(screen.getByText("0 9 * * *")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Pause" })); + await waitFor(() => + expect(pauseLocalSchedule).toHaveBeenCalledWith("daily-report"), + ); + + await user.click(screen.getByRole("button", { name: "Remove schedule" })); + expect( + await screen.findByText(/Remove local schedule "daily-report"\?/), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Remove schedule" })); + await waitFor(() => + expect(removeLocalSchedule).toHaveBeenCalledWith("daily-report"), + ); + }); + + it("stops the active run without removing its local schedule", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(listLocalSchedules).mockResolvedValue([ + { + id: "running-report", + source: "/recipes/running-report.yaml", + cron: "0 9 * * *", + lastRun: null, + currentlyRunning: true, + paused: false, + currentSessionId: "session-1", + jobStartTime: "2026-08-28T09:00:00Z", + }, + ]); + + renderAutomationsView(); + + expect(await screen.findByRole("button", { name: "Pause" })).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Remove schedule" }), + ).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Stop run" })); + await waitFor(() => + expect(killLocalScheduleRun).toHaveBeenCalledWith("running-report"), + ); + expect(removeLocalSchedule).not.toHaveBeenCalled(); + }); + it("renders markdown emphasis in overview summaries", async () => { vi.mocked(getAutomationTiles).mockResolvedValue({ tiles: [ @@ -257,6 +340,19 @@ describe("AutomationsView", () => { expect(screen.queryByText(/\*\*Great Blue Heron\*\*/)).toBeNull(); }); + it("surfaces local schedule load failures", async () => { + vi.mocked(listLocalSchedules).mockRejectedValue( + new Error("Live scheduler unavailable"), + ); + + renderAutomationsView(); + + expect( + await screen.findByText("Local schedules failed to load"), + ).toBeInTheDocument(); + expect(screen.getByText("Live scheduler unavailable")).toBeInTheDocument(); + }); + it("opens automation details from the overview", async () => { const user = userEvent.setup(); renderAutomationsView(); diff --git a/src/features/automations/ui/AutomationsView.tsx b/src/features/automations/ui/AutomationsView.tsx index fc2586fe7..2ff7391c1 100644 --- a/src/features/automations/ui/AutomationsView.tsx +++ b/src/features/automations/ui/AutomationsView.tsx @@ -28,8 +28,17 @@ import { import { AutomationBuilderView } from "@/features/automations/ui/AutomationBuilderView"; import type { AutomationBuilderLeaveAction } from "@/features/automations/ui/AutomationBuilderView"; import { AutomationDetailPage } from "@/features/automations/ui/AutomationDetailPage"; +import { + killLocalScheduleRun, + listLocalSchedules, + LOCAL_SCHEDULES_QUERY_KEY, + pauseLocalSchedule, + removeLocalSchedule, + unpauseLocalSchedule, +} from "@/features/automations/api/localSchedules"; import { AutomationHistoryFeed } from "@/features/automations/ui/AutomationHistoryFeed"; import { AutomationsOverview } from "@/features/automations/ui/AutomationsOverview"; +import { LocalSchedulesPanel } from "@/features/automations/ui/LocalSchedulesPanel"; import { EmptyState } from "@/features/automations/ui/RunOutput"; import { Badge } from "@/shared/ui/badge"; import { PageHeaderButton } from "@/shared/ui/page-header-button"; @@ -112,6 +121,9 @@ export function AutomationsWorkbench({ null, ); const [mutationError, setMutationError] = useState(null); + const [removeLocalScheduleId, setRemoveLocalScheduleId] = useState< + string | null + >(null); const setNavigationRoute = useCallback( ( @@ -138,6 +150,16 @@ export function AutomationsWorkbench({ }); const automations = useMemo(() => automationsData ?? [], [automationsData]); + const { + data: localSchedules = [], + error: localSchedulesError, + isLoading: isLocalSchedulesLoading, + refetch: refetchLocalSchedules, + } = useQuery({ + queryKey: LOCAL_SCHEDULES_QUERY_KEY, + queryFn: listLocalSchedules, + refetchInterval: AUTOMATIONS_REFETCH_INTERVAL_MS, + }); useEffect(() => { if (!automations.length) { @@ -348,6 +370,38 @@ export function AutomationsWorkbench({ }, }); + const localScheduleMutation = useMutation({ + mutationFn: async ({ + action, + scheduleId, + }: { + action: "pause" | "unpause" | "kill" | "remove"; + scheduleId: string; + }) => { + switch (action) { + case "pause": + return pauseLocalSchedule(scheduleId); + case "unpause": + return unpauseLocalSchedule(scheduleId); + case "kill": + return killLocalScheduleRun(scheduleId); + case "remove": + return removeLocalSchedule(scheduleId); + } + }, + onSuccess: async () => { + setRemoveLocalScheduleId(null); + await queryClient.invalidateQueries({ + queryKey: LOCAL_SCHEDULES_QUERY_KEY, + }); + }, + onError: (error) => { + toast.error(t("localSchedules.actionError"), { + description: errorMessage(error, t("localSchedules.actionError")), + }); + }, + }); + const duplicateMutation = useMutation({ mutationFn: (tile: AutomationTile) => { const request = buildDuplicateAutomationRequest( @@ -463,7 +517,10 @@ export function AutomationsWorkbench({ <> refetchAutomations()} + onClick={() => { + void refetchAutomations(); + void refetchLocalSchedules(); + }} aria-label={t("actions.refresh")} tooltip={t("actions.refresh")} leftIcon={