diff --git a/docs/plans/goose-schedules-in-berd.md b/docs/plans/goose-schedules-in-berd.md new file mode 100644 index 000000000..a4e3691e1 --- /dev/null +++ b/docs/plans/goose-schedules-in-berd.md @@ -0,0 +1,30 @@ +# Goose schedules in Berd: proposed stack + +## Product boundary + +Berd should expose the scheduler already owned by the embedded `goosed` process. Local Goose schedules belong in the shared Automations hub, next to company-managed automations when that service is available, but neither product should depend on the other. OSS builds must never issue KGoose/G2 requests just to show local schedules. The app should clearly distinguish local schedules from company-managed automation records. + +The current Berd pin already includes typed ACP schedule list, update, pause/resume, delete, run-stop, and per-schedule session-history requests. Initial UI work therefore needs no backend pin or SDK regeneration. The scheduler may be disabled or unavailable on some ACP servers; surface that as a local-scheduler error, isolated from the company-managed section. + +## Proposed PR stack + +| Layer | Scope | Acceptance | +|---|---|---| +| 1. `feat(automations): show local Goose schedules in OSS` | Give local schedules an OSS capability independent of the KGoose-gated company Automations capability; mount a read-only local schedule list in the shared hub; keep company-only details, history, builder, and requests gated. | With company automations disabled, list local jobs and status, and prove that company list/detail requests are not made. In the Block build, both sources remain visible in the same hub. | +| 2. `feat(automations): configure local Goose schedules` | Add guarded cron editing to the embedded schedule list, with supported-expression validation and save/cancel states. | Invalid input is rejected before reaching ACP, and valid updates use the typed update method against the live scheduler. The current Goose update handler mutates the active job before validating the new cron, so the UI must not pass unsupported expressions. | +| 3. `feat(automations): manage local Goose schedules` | Add pause/resume, stop active runs, confirmed removal, and bounded per-schedule run history on demand. Share the local ACP adapter between UI and `berdctl` if/when CLI controls are restacked. | Mutations use typed ACP on the same live Goose process; enforce running-job restrictions; removal preserves the recipe; history is fetched only when requested and remains bounded. Review deletion behavior against the exact Goose version before adding any multi-step pause/delete/rollback sequence. | +| Future upstream dependency | Add durable project/workspace context to scheduled jobs and pass it into every run's session creation. Then expose project filtering/association in Berd. | Schedule association survives restart and each execution uses the selected project's stable ID and workspace roots. | + +Each proposed layer should be reviewed as a working prefix. The current first implementation covers only Layer 1; the later controls and upstream project-scoped execution work are planned separately because they carry distinct behavioral risks. + +## Project association + +A real association cannot be implemented accurately as UI-only metadata. In the pinned scheduler, job executions create sessions at the Goose process's current working directory. The schedule DTO and create/update requests do not contain a project ID, cwd, or additional workspace roots. Berd projects do have stable IDs, but attaching one only to display metadata would mislead users about where scheduled agents actually run. A later upstream Goose change should persist project/workspace context on each job and apply it when every scheduled session is created; Berd can then select a project and navigate run history into the right project. + +## Performance and JEV + +No JEV package, capability, or reference exists in this repository or the checked dependency manifests. Do not introduce or assume one without identifying the intended library. Schedule listing is one bounded UI query that shares the existing 15-second refresh cadence; TanStack Query handles cache lifecycle. TanStack Virtual is already installed and used for long session lists, but should only be applied if realistic schedule volumes make rendering measurable. Per-schedule history should load on demand with a small limit rather than polling history for every schedule. If later performance measurements show polling or large result sets are a bottleneck, first consolidate query ownership and seek server pagination/push updates before adding virtualization alone. + +## Reference and known issue + +PR #245 (`block/berd#245`) demonstrates the same embedded ACP scheduler path, but mixes local schedules into a company Automations workbench that still mounts company-backed polling and builder flows. It also includes CLI schedule commands. Reuse its typed ACP approach, but keep OSS surface gating separate. Review schedule removal against the live backend before adding a client-side pause/delete/rollback transaction; that sequence can race another actor, and rollback failure can hide the original deletion error. Also gate company detail actions synchronously: Query's `enabled: false` does not stop an explicit `refetch()`. diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 73ccd8f90..aa1abfd74 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -740,6 +740,8 @@ export function AppShell({ const capabilities = useProfileCapabilities(); useRemoteSessionExperimentReconciliation(); const isAutomationsFeatureEnabled = capabilities.automations; + const isAutomationsSurfaceEnabled = + capabilities.automations || capabilities.localSchedules; const isBuilderbotSurfaceEnabled = capabilities.builderbot; const isFeedbackEnabled = capabilities.feedback; const sessionWindowSupport = useSessionWindowSupport(); @@ -1401,10 +1403,22 @@ export function AppShell({ if (activeView === "builderbot" && !isBuilderbotSurfaceEnabled) { setActiveView("home"); } - if (activeView === "automations" && !isAutomationsFeatureEnabled) { + if (activeView === "automations" && !isAutomationsSurfaceEnabled) { setActiveView("home"); } - }, [activeView, isAutomationsFeatureEnabled, isBuilderbotSurfaceEnabled]); + if ( + !isAutomationsFeatureEnabled && + automationsRoute.surface !== "overview" + ) { + setAutomationsRoute({ surface: "overview" }); + } + }, [ + activeView, + automationsRoute.surface, + isAutomationsFeatureEnabled, + isAutomationsSurfaceEnabled, + isBuilderbotSurfaceEnabled, + ]); useEffect(() => { const enabledSection = resolveEnabledSettingsSection( @@ -4444,7 +4458,7 @@ export function AppShell({ (view: AppView) => { guardAppNavigation(() => { resetGlobalComposerTransition(); - if (view === "automations" && !isAutomationsFeatureEnabled) { + if (view === "automations" && !isAutomationsSurfaceEnabled) { setActiveView("home"); return; } @@ -4485,7 +4499,7 @@ export function AppShell({ guardAppNavigation, resetGlobalComposerTransition, setActiveSession, - isAutomationsFeatureEnabled, + isAutomationsSurfaceEnabled, isBuilderbotSurfaceEnabled, ], ); @@ -4566,7 +4580,7 @@ export function AppShell({ route: AutomationNavigationRoute, options?: AppNavigationUpdateOptions, ) => { - if (!isAutomationsFeatureEnabled) { + if (!isAutomationsSurfaceEnabled) { return; } guardAppNavigation(() => { @@ -4577,7 +4591,7 @@ export function AppShell({ setActiveView("automations"); }); }, - [guardAppNavigation, isAutomationsFeatureEnabled, setActiveSession], + [guardAppNavigation, isAutomationsSurfaceEnabled, setActiveSession], ); const navigateBuilderbot = useCallback( @@ -4643,7 +4657,7 @@ export function AppShell({ } if (location.view === "automations") { - if (!isAutomationsFeatureEnabled) { + if (!isAutomationsSurfaceEnabled) { setActiveSession(null); setActiveView("home"); return; @@ -4691,7 +4705,7 @@ export function AppShell({ }, [ expandSidebar, - isAutomationsFeatureEnabled, + isAutomationsSurfaceEnabled, isBuilderbotSurfaceEnabled, setActiveSession, setChatActiveSession, @@ -5621,7 +5635,8 @@ export function AppShell({ onDesignSystemSectionChange={selectDesignSystemSection} authStatus={authStatus} isPreparingContent={isPreparingContent} - automationsEnabled={isAutomationsFeatureEnabled} + automationsEnabled={isAutomationsSurfaceEnabled} + companyAutomationsEnabled={isAutomationsFeatureEnabled} builderbotEnabled={isBuilderbotSurfaceEnabled} renderedSession={renderedSession} homeSessionId={homeSessionId} diff --git a/src/app/ui/AppShellContent.tsx b/src/app/ui/AppShellContent.tsx index f9aab8809..ea0dff962 100644 --- a/src/app/ui/AppShellContent.tsx +++ b/src/app/ui/AppShellContent.tsx @@ -48,6 +48,7 @@ interface AppShellContentProps { onLoggedOut?: (status: AuthStatus) => void; isPreparingContent: boolean; automationsEnabled: boolean; + companyAutomationsEnabled?: boolean; builderbotEnabled: boolean; renderedSession?: ChatSession; homeSessionId: string | null; @@ -133,6 +134,7 @@ export function AppShellContent({ authStatus, isPreparingContent, automationsEnabled, + companyAutomationsEnabled = automationsEnabled, builderbotEnabled, designSystemInspectorVisible, onCloseDesignSystem, @@ -196,7 +198,7 @@ export function AppShellContent({ targetLocation, }); - const openHomeAutomation = automationsEnabled + const openHomeAutomation = companyAutomationsEnabled ? (automationId: string) => onNavigateAutomations({ surface: "detail", @@ -205,7 +207,7 @@ export function AppShellContent({ selectedRunKey: null, }) : undefined; - const openHomeAutomations = automationsEnabled + const openHomeAutomations = companyAutomationsEnabled ? () => onNavigateAutomations({ surface: "overview" }) : undefined; const setupRequiredContent = homeProviderSetupRequired ? ( @@ -239,6 +241,7 @@ export function AppShellContent({ const routeContent = renderRouteContent({ authStatus, automationsEnabled, + companyAutomationsEnabled, builderbotEnabled, chatComposerHandoffActive, chatComposerHandoffInProgress, @@ -306,6 +309,7 @@ export function AppShellContent({ interface RenderRouteContentOptions { authStatus?: AuthStatus; automationsEnabled: boolean; + companyAutomationsEnabled?: boolean; builderbotEnabled: boolean; chatComposerHandoffActive: boolean; chatComposerHandoffInProgress: boolean; @@ -381,6 +385,7 @@ interface RenderRouteContentOptions { function renderRouteContent({ automationsEnabled, + companyAutomationsEnabled = automationsEnabled, builderbotEnabled, chatComposerHandoffActive, chatComposerHandoffInProgress, @@ -456,6 +461,7 @@ function renderRouteContent({ case "automations": return automationsEnabled ? ( vi.fn()); + +vi.mock("@/shared/api/acpConnection", () => ({ + getClient: vi.fn(async () => ({ + goose: { GooseUnstableSchedulesList: listSchedules }, + })), +})); + +describe("local Goose schedules API", () => { + beforeEach(() => { + vi.clearAllMocks(); + listSchedules.mockResolvedValue({ + jobs: [ + { + id: "weekday-report", + source: "/tmp/scheduled_recipes/weekday-report.yaml", + cron: "0 9 * * 1-5", + currentlyRunning: false, + paused: false, + }, + ], + }); + }); + + it("lists schedules through the connected Goose instance", async () => { + await expect(listLocalSchedules()).resolves.toEqual([ + { + id: "weekday-report", + source: "/tmp/scheduled_recipes/weekday-report.yaml", + cron: "0 9 * * 1-5", + currentlyRunning: false, + paused: false, + }, + ]); + expect(getClient).toHaveBeenCalledOnce(); + expect(listSchedules).toHaveBeenCalledWith({}); + }); +}); diff --git a/src/features/automations/api/localSchedules.ts b/src/features/automations/api/localSchedules.ts new file mode 100644 index 000000000..bb614def3 --- /dev/null +++ b/src/features/automations/api/localSchedules.ts @@ -0,0 +1,11 @@ +import type { ScheduledJobDto } from "@aaif/goose-sdk"; +import { getClient } from "@/shared/api/acpConnection"; + +export const LOCAL_SCHEDULES_QUERY_KEY = ["local-goose-schedules"] as const; +export const LOCAL_SCHEDULES_REFETCH_INTERVAL_MS = 15_000; + +export async function listLocalSchedules(): Promise { + const client = await getClient(); + const response = await client.goose.GooseUnstableSchedulesList({}); + return response.jobs; +} diff --git a/src/features/automations/ui/AutomationsView.test.tsx b/src/features/automations/ui/AutomationsView.test.tsx index b3ac5ad22..a4948085c 100644 --- a/src/features/automations/ui/AutomationsView.test.tsx +++ b/src/features/automations/ui/AutomationsView.test.tsx @@ -1,7 +1,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor, within } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { listLocalSchedules } from "@/features/automations/api/localSchedules"; import { createAutomationTile, deleteAutomationTile, @@ -33,6 +34,12 @@ if (!HTMLElement.prototype.scrollIntoView) { HTMLElement.prototype.scrollIntoView = () => {}; } +vi.mock("@/features/automations/api/localSchedules", () => ({ + LOCAL_SCHEDULES_QUERY_KEY: ["local-goose-schedules"], + LOCAL_SCHEDULES_REFETCH_INTERVAL_MS: 15_000, + listLocalSchedules: vi.fn().mockResolvedValue([]), +})); + vi.mock("@/features/automations/api/kgooseAutomations", () => ({ getAutomationTiles: vi.fn(), getAutomationTile: vi.fn(), @@ -68,12 +75,14 @@ vi.mock("@/features/automations/ui/AutomationBuilderView", () => ({ })); function renderAutomationsView(props?: { + companyAutomationsEnabled?: boolean; route?: AutomationNavigationRoute; onRouteChange?: ( route: AutomationNavigationRoute, options?: { replace?: boolean }, ) => void; }) { + const { companyAutomationsEnabled = true, ...routeProps } = props ?? {}; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -84,7 +93,10 @@ function renderAutomationsView(props?: { - + , ); @@ -108,6 +120,7 @@ describe("AutomationsView", () => { beforeEach(() => { resetHomeWidgetStoreForTests(); vi.clearAllMocks(); + vi.mocked(listLocalSchedules).mockResolvedValue([]); vi.mocked(getAutomationTiles).mockResolvedValue({ tiles: [ { @@ -236,6 +249,189 @@ describe("AutomationsView", () => { expect(screen.getByText("Revenue was up.")).toBeInTheDocument(); }); + it("shows local schedules without invoking company-managed automation APIs", async () => { + vi.mocked(listLocalSchedules).mockResolvedValue([ + { + id: "local-daily-report", + source: "Daily report", + cron: "0 9 * * *", + paused: false, + currentlyRunning: false, + }, + ]); + renderAutomationsView({ companyAutomationsEnabled: false }); + + expect(await screen.findByText("local-daily-report")).toBeInTheDocument(); + expect( + screen.getByText( + "View schedules configured in your local Goose instance.", + ), + ).toBeInTheDocument(); + expect(getAutomationTiles).not.toHaveBeenCalled(); + expect(getAutomationTile).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "History" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Add automation" })).toBeNull(); + }); + + it("does not execute a scheduled retry after capability revocation", async () => { + vi.useFakeTimers(); + let rejectRequest!: (error: Error) => void; + vi.mocked(getAutomationTiles) + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRequest = reject; + }), + ) + .mockResolvedValue({ tiles: [] }); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + retryDelay: 1_000, + }, + }, + }); + const app = render( + + + + + + , + ); + expect(getAutomationTiles).toHaveBeenCalledOnce(); + await act(async () => { + rejectRequest(new Error("first request failed")); + await Promise.resolve(); + }); + + app.rerender( + + + + + + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + + expect(getAutomationTiles).toHaveBeenCalledOnce(); + vi.useRealTimers(); + }); + + it("stops company-query retries after capability revocation", async () => { + let rejectRequest!: (error: Error) => void; + vi.mocked(getAutomationTiles).mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRequest = reject; + }), + ); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: true, retryDelay: 0 } }, + }); + const app = render( + + + + + + , + ); + expect(getAutomationTiles).toHaveBeenCalledOnce(); + + app.rerender( + + + + + + , + ); + await act(async () => { + rejectRequest(new Error("company service unavailable")); + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + + expect(getAutomationTiles).toHaveBeenCalledOnce(); + }); + + it("clears company deletion confirmation when the company capability is revoked", async () => { + const user = userEvent.setup(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const app = render( + + + + + + , + ); + await user.click( + await screen.findByRole("button", { name: "Daily revenue digest" }), + ); + await user.click(screen.getByRole("button", { name: "Delete" })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + app.rerender( + + + + + + , + ); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect(deleteAutomationTile).not.toHaveBeenCalled(); + }); + + it("keeps company automations visible when the local scheduler fails", async () => { + vi.mocked(listLocalSchedules).mockRejectedValue( + new Error("scheduler disabled"), + ); + renderAutomationsView(); + + expect( + await screen.findByText("Could not load Goose schedules"), + ).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "Company-managed automations" }), + ).toBeInTheDocument(); + expect( + await screen.findByRole("button", { name: "Daily revenue digest" }), + ).toBeInTheDocument(); + }); + + it("shows Goose schedules with company automations in the same overview when enabled", async () => { + vi.mocked(listLocalSchedules).mockResolvedValue([ + { + id: "local-daily-report", + source: "/tmp/scheduled_recipes/daily-report.yaml", + cron: "0 9 * * *", + paused: false, + currentlyRunning: false, + }, + ]); + renderAutomationsView(); + + await screen.findByRole("button", { name: "Daily revenue digest" }); + expect(await screen.findByText("Goose schedules")).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "Company-managed automations" }), + ).toBeInTheDocument(); + expect(screen.getByText("local-daily-report")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Daily revenue digest" }), + ).toBeInTheDocument(); + }); + it("renders markdown emphasis in overview summaries", async () => { vi.mocked(getAutomationTiles).mockResolvedValue({ tiles: [ @@ -1010,21 +1206,43 @@ describe("AutomationsView", () => { ).toBeInTheDocument(); }); - it("renders kgoose access failures on the overview", async () => { - vi.mocked(getAutomationTiles).mockRejectedValue( - "Unable to reach the internal service. Please check that you're connected to Cloudflare WARP and try again.", - ); + it("does not request company automations when that capability is disabled", async () => { + renderAutomationsView({ companyAutomationsEnabled: false }); + await screen.findByText("No local schedules yet"); + expect(getAutomationTiles).not.toHaveBeenCalled(); + expect(getAutomationTile).not.toHaveBeenCalled(); + }); - renderAutomationsView(); + it("requests company automations only when the company capability is enabled", async () => { + vi.mocked(getAutomationTiles).mockResolvedValue({ + tiles: [ + { + id: "automation-1", + title: "Daily revenue digest", + schedule: "0 9 * * *", + }, + ], + }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, retryDelay: 0 } }, + }); + render( + + + + + + , + ); expect( - await screen.findByText("Automations failed to load"), - ).toBeInTheDocument(); - expect( - screen.getByText( - "Unable to reach the internal service. Please check that you're connected to Cloudflare WARP and try again.", - ), + await screen.findByRole("heading", { + name: "Company-managed automations", + }), ).toBeInTheDocument(); + expect(getAutomationTiles).toHaveBeenCalledOnce(); + expect(getAutomationTile).not.toHaveBeenCalled(); + expect(await screen.findByText("Daily revenue digest")).toBeInTheDocument(); }); it("edits a generic automation title", async () => { diff --git a/src/features/automations/ui/AutomationsView.tsx b/src/features/automations/ui/AutomationsView.tsx index fc2586fe7..b56986efe 100644 --- a/src/features/automations/ui/AutomationsView.tsx +++ b/src/features/automations/ui/AutomationsView.tsx @@ -14,6 +14,7 @@ import { } from "@/features/automations/api/kgooseAutomations"; import { AUTOMATION_TILES_QUERY_KEY, + AUTOMATION_TILES_STALE_TIME_MS, AUTOMATIONS_REFETCH_INTERVAL_MS, automationTileQueryKey, fetchAutomationTileDetail, @@ -30,6 +31,7 @@ import type { AutomationBuilderLeaveAction } from "@/features/automations/ui/Aut import { AutomationDetailPage } from "@/features/automations/ui/AutomationDetailPage"; 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"; @@ -68,6 +70,7 @@ function errorMessage(error: unknown, fallback: string): string { type AutomationSurfaceMode = "overview" | "history"; interface AutomationsWorkbenchProps { + companyAutomationsEnabled?: boolean; route?: AutomationNavigationRoute; onRouteChange?: ( route: AutomationNavigationRoute, @@ -80,6 +83,7 @@ interface AutomationsWorkbenchProps { } export function AutomationsWorkbench({ + companyAutomationsEnabled = true, route, onRouteChange, onBreadcrumbLabelChange, @@ -92,8 +96,12 @@ export function AutomationsWorkbench({ { surface: "overview" }, ); const currentRoute = route ?? internalRoute; + const companyAutomationsEnabledRef = useRef(companyAutomationsEnabled); + companyAutomationsEnabledRef.current = companyAutomationsEnabled; const surfaceMode: AutomationSurfaceMode = - currentRoute.surface === "history" ? "history" : "overview"; + companyAutomationsEnabled && currentRoute.surface === "history" + ? "history" + : "overview"; const detailAutomationId = currentRoute.surface === "detail" ? currentRoute.automationId : null; const detailTab = @@ -133,13 +141,31 @@ export function AutomationsWorkbench({ refetch: refetchAutomations, } = useQuery({ queryKey: AUTOMATION_TILES_QUERY_KEY, - queryFn: fetchAutomationTilesList, - refetchInterval: AUTOMATIONS_REFETCH_INTERVAL_MS, + queryFn: () => + companyAutomationsEnabledRef.current + ? fetchAutomationTilesList() + : Promise.resolve([]), + enabled: companyAutomationsEnabled, + retry: (failureCount) => + companyAutomationsEnabledRef.current && failureCount < 1, + staleTime: AUTOMATION_TILES_STALE_TIME_MS, + refetchInterval: companyAutomationsEnabled + ? AUTOMATIONS_REFETCH_INTERVAL_MS + : false, }); const automations = useMemo(() => automationsData ?? [], [automationsData]); useEffect(() => { + if (!companyAutomationsEnabled) { + if (delayedRefetchTimeoutRef.current) { + clearTimeout(delayedRefetchTimeoutRef.current); + delayedRefetchTimeoutRef.current = null; + } + setDeleteAutomationId(null); + setMutationError(null); + return; + } if (!automations.length) { if ( !isAutomationsLoading && @@ -176,6 +202,7 @@ export function AutomationsWorkbench({ } }, [ automations, + companyAutomationsEnabled, isAutomationsLoading, currentRoute.surface, detailAutomationId, @@ -197,9 +224,17 @@ export function AutomationsWorkbench({ refetch: refetchDetail, } = useQuery({ queryKey: automationTileQueryKey(detailAutomationId), - queryFn: () => fetchAutomationTileDetail(detailAutomationId ?? ""), - enabled: Boolean(detailAutomationId), - refetchInterval: AUTOMATIONS_REFETCH_INTERVAL_MS, + queryFn: () => + companyAutomationsEnabledRef.current && detailAutomationId + ? fetchAutomationTileDetail(detailAutomationId) + : Promise.resolve({ tileInfo: undefined }), + enabled: companyAutomationsEnabled && Boolean(detailAutomationId), + retry: (failureCount) => + companyAutomationsEnabledRef.current && failureCount < 1, + staleTime: AUTOMATIONS_REFETCH_INTERVAL_MS, + refetchInterval: companyAutomationsEnabled + ? AUTOMATIONS_REFETCH_INTERVAL_MS + : false, }); const detailTile = detailData?.tileInfo ?? detailAutomation; @@ -209,6 +244,10 @@ export function AutomationsWorkbench({ : t("fallbacks.untitledAutomation"); useEffect(() => { + if (!companyAutomationsEnabled) { + onBreadcrumbLabelChange?.(null); + return; + } if (currentRoute.surface === "builder") { onBreadcrumbLabelChange?.(t("builder.title")); return; @@ -233,6 +272,7 @@ export function AutomationsWorkbench({ detailTile, onBreadcrumbLabelChange, t, + companyAutomationsEnabled, ]); useEffect(() => { @@ -260,8 +300,10 @@ export function AutomationsWorkbench({ // kgoose list propagation can lag tile creation, so refetch once more // after the immediate refresh. delayedRefetchTimeoutRef.current = setTimeout(() => { - void refetchAutomations(); delayedRefetchTimeoutRef.current = null; + if (companyAutomationsEnabledRef.current) { + void refetchAutomations(); + } }, 1_500); }; @@ -290,6 +332,7 @@ export function AutomationsWorkbench({ const updateMutation = useMutation({ mutationFn: updateAutomationTile, onSuccess: async (response) => { + if (!companyAutomationsEnabledRef.current) return; if (response.success === false) { setMutationError(response.errorMsg ?? t("edit.saveError")); return; @@ -298,6 +341,7 @@ export function AutomationsWorkbench({ await invalidateAutomationQueries(); }, onError: (error) => { + if (!companyAutomationsEnabledRef.current) return; setMutationError(errorMessage(error, t("edit.saveError"))); }, }); @@ -305,6 +349,7 @@ export function AutomationsWorkbench({ const deleteMutation = useMutation({ mutationFn: deleteAutomationTile, onSuccess: async (response) => { + if (!companyAutomationsEnabledRef.current) return; if (response.success === false) { setMutationError(response.errorMsg ?? t("delete.error")); return; @@ -315,6 +360,7 @@ export function AutomationsWorkbench({ await invalidateAutomationQueries(); }, onError: (error) => { + if (!companyAutomationsEnabledRef.current) return; setMutationError(errorMessage(error, t("delete.error"))); }, }); @@ -322,6 +368,7 @@ export function AutomationsWorkbench({ const refreshMutation = useMutation({ mutationFn: refreshAutomationTile, onSuccess: async (response, automationId) => { + if (!companyAutomationsEnabledRef.current) return; if (response.success === false) { toast.error(t("run.error"), { description: response.errorMsg ?? t("run.errorDescription"), @@ -337,11 +384,13 @@ export function AutomationsWorkbench({ }); await invalidateAutomationQueries(); window.setTimeout(() => { + if (!companyAutomationsEnabledRef.current) return; void refetchAutomations(); void refetchDetail(); }, 250); }, onError: (error) => { + if (!companyAutomationsEnabledRef.current) return; toast.error(t("run.error"), { description: errorMessage(error, t("run.errorDescription")), }); @@ -360,9 +409,11 @@ export function AutomationsWorkbench({ return createAutomationTile(request); }, onMutate: () => { + if (!companyAutomationsEnabledRef.current) return; setMutationError(null); }, onSuccess: async (response) => { + if (!companyAutomationsEnabledRef.current) return; if (response.success === false) { setMutationError(response.errorMsg ?? t("duplicate.error")); return; @@ -377,6 +428,7 @@ export function AutomationsWorkbench({ } }, onError: (error) => { + if (!companyAutomationsEnabledRef.current) return; setMutationError(errorMessage(error, t("duplicate.error"))); }, }); @@ -415,7 +467,7 @@ export function AutomationsWorkbench({ : t("common:actions.pinToHome"); useEffect(() => { - if (currentRoute.surface === "builder") { + if (!companyAutomationsEnabled || currentRoute.surface === "builder") { setTopBarActions(null); return; } @@ -461,24 +513,28 @@ export function AutomationsWorkbench({ setTopBarActions( <> - refetchAutomations()} - aria-label={t("actions.refresh")} - tooltip={t("actions.refresh")} - leftIcon={ - + {companyAutomationsEnabled ? ( + refetchAutomations()} + aria-label={t("actions.refresh")} + tooltip={t("actions.refresh")} + leftIcon={ + ) : null} + {companyAutomationsEnabled ? ( + + ) : null} , ); return () => setTopBarActions(null); @@ -493,26 +549,30 @@ export function AutomationsWorkbench({ refreshMutate, setTopBarActions, t, + companyAutomationsEnabled, ]); - if (currentRoute.surface === "builder") { + if (currentRoute.surface === "builder" && companyAutomationsEnabled) { return ( { + if (!companyAutomationsEnabledRef.current) return; if (automationId) { selectCreatedAutomation(automationId); } void refetchAutomations().then(() => { - if (!automationId) return; + if (!companyAutomationsEnabledRef.current || !automationId) return; scheduleDelayedAutomationsRefetch(); }); }} onAutomationUpdated={(automationId) => { + if (!companyAutomationsEnabledRef.current) return; if (automationId) { selectCreatedAutomation(automationId); } void refetchAutomations().then(() => { + if (!companyAutomationsEnabledRef.current) return; void refetchDetail(); }); }} @@ -524,7 +584,23 @@ export function AutomationsWorkbench({ return ( <> - {detailAutomationId ? ( + {!companyAutomationsEnabled || + currentRoute.surface === "overview" || + currentRoute.surface === "history" ? ( +
+

+ {t("localSchedules.hubTitle")} +

+

+ {t( + companyAutomationsEnabled + ? "localSchedules.hubDescription" + : "localSchedules.hubLocalOnlyDescription", + )} +

+
+ ) : null} + {detailAutomationId && companyAutomationsEnabled ? ( isDetailLoading && !detailTile ? (
@@ -618,86 +694,109 @@ export function AutomationsWorkbench({ {t("tabs.overview")} - - {t("tabs.history")} - + {companyAutomationsEnabled ? ( + + {t("tabs.history")} + + ) : null} - - {isAutomationsLoading ? ( -
-
-
-
-
- ) : automationsError ? ( - + + {companyAutomationsEnabled ? ( +
+

+ {t("companyAutomations.title")} +

+ {isAutomationsLoading ? ( +
+
+
+
+
+ ) : automationsError ? ( +
+ +
+ ) : automations.length ? ( +
+ +
+ ) : ( +
+ +
)} - /> - ) : automations.length ? ( - - ) : ( - - )} +
+ ) : null} - - {automations.length ? ( - - setNavigationRoute({ - surface: "history", - selectedRun, - }) - } - onOpenAutomation={({ automationId, runKey }) => - openRunDetail(automationId, runKey) - } - /> - ) : ( - - )} - + {companyAutomationsEnabled ? ( + + {automations.length ? ( + + setNavigationRoute({ + surface: "history", + selectedRun, + }) + } + onOpenAutomation={({ automationId, runKey }) => + openRunDetail(automationId, runKey) + } + /> + ) : ( + + )} + + ) : null} )} - { - if (!open) { - setDeleteAutomationId(null); - } - }} - title={t("delete.title")} - description={t("delete.description", { - name: deleteAutomationName, - })} - cancelLabel={t("actions.cancel")} - confirmLabel={t("actions.delete")} - loadingLabel={t("actions.deleting")} - isLoading={deleteMutation.isPending} - onConfirm={() => { - if (deleteAutomationId) { - deleteMutation.mutate(deleteAutomationId); - } - }} - /> + {companyAutomationsEnabled ? ( + { + if (!open) { + setDeleteAutomationId(null); + } + }} + title={t("delete.title")} + description={t("delete.description", { + name: deleteAutomationName, + })} + cancelLabel={t("actions.cancel")} + confirmLabel={t("actions.delete")} + loadingLabel={t("actions.deleting")} + isLoading={deleteMutation.isPending} + onConfirm={() => { + if (deleteAutomationId) { + deleteMutation.mutate(deleteAutomationId); + } + }} + /> + ) : null} ); } diff --git a/src/features/automations/ui/LocalSchedulesPanel.test.tsx b/src/features/automations/ui/LocalSchedulesPanel.test.tsx new file mode 100644 index 000000000..1d07f0202 --- /dev/null +++ b/src/features/automations/ui/LocalSchedulesPanel.test.tsx @@ -0,0 +1,107 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { formatDate } from "@/shared/i18n"; +import { listLocalSchedules } from "@/features/automations/api/localSchedules"; +import { LocalSchedulesPanel } from "./LocalSchedulesPanel"; + +vi.mock("@/features/automations/api/localSchedules", () => ({ + LOCAL_SCHEDULES_QUERY_KEY: ["local-goose-schedules"], + LOCAL_SCHEDULES_REFETCH_INTERVAL_MS: 15_000, + listLocalSchedules: vi.fn(), +})); + +function renderPanel() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +const schedule = { + id: "weekday-report", + source: "/Users/test/.config/goose/scheduled_recipes/weekday-report.yaml", + cron: "0 9 * * 1-5", + paused: false, + currentlyRunning: false, +}; + +describe("LocalSchedulesPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(listLocalSchedules).mockResolvedValue([schedule]); + }); + + it("lists local Goose schedules and their current state", async () => { + renderPanel(); + + expect(await screen.findByText("weekday-report")).toBeInTheDocument(); + expect( + screen.getByText("Recipe file: weekday-report.yaml"), + ).toBeInTheDocument(); + expect(screen.getByText("0 9 * * 1-5")).toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + + it("labels paused schedules instead of active", async () => { + vi.mocked(listLocalSchedules).mockResolvedValue([ + { ...schedule, paused: true }, + ]); + renderPanel(); + expect(await screen.findByText("Paused")).toBeInTheDocument(); + expect(screen.queryByText("Active")).not.toBeInTheDocument(); + }); + + it("formats last run with the app locale", async () => { + const lastRun = "2026-11-04T09:00:00Z"; + vi.mocked(listLocalSchedules).mockResolvedValue([{ ...schedule, lastRun }]); + renderPanel(); + const formatted = formatDate(lastRun, { + dateStyle: "medium", + timeStyle: "short", + }); + expect( + await screen.findByText(`Last run: ${formatted}`), + ).toBeInTheDocument(); + expect(screen.queryByText(lastRun)).not.toBeInTheDocument(); + }); + + it("uses a safe fallback for invalid last run values", async () => { + vi.mocked(listLocalSchedules).mockResolvedValue([ + { ...schedule, lastRun: "invalid" }, + ]); + renderPanel(); + expect(await screen.findByText("Last run: Unknown")).toBeInTheDocument(); + }); + + it("shows the empty state without hiding the Goose schedules section", async () => { + vi.mocked(listLocalSchedules).mockResolvedValue([]); + renderPanel(); + + expect( + await screen.findByText("No local schedules yet"), + ).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "Goose schedules" }), + ).toBeInTheDocument(); + }); + + it("shows the scheduler error when the first local-schedule request fails", async () => { + vi.mocked(listLocalSchedules).mockRejectedValue( + new Error("scheduler disabled"), + ); + renderPanel(); + + expect( + await screen.findByText("Could not load Goose schedules"), + ).toBeInTheDocument(); + const alert = screen.getByRole("alert"); + expect(alert).toHaveAttribute("data-slot", "alert"); + expect(alert).toHaveTextContent("Could not load Goose schedules"); + expect(alert).toHaveTextContent("scheduler disabled"); + }); +}); diff --git a/src/features/automations/ui/LocalSchedulesPanel.tsx b/src/features/automations/ui/LocalSchedulesPanel.tsx new file mode 100644 index 000000000..4099333c8 --- /dev/null +++ b/src/features/automations/ui/LocalSchedulesPanel.tsx @@ -0,0 +1,146 @@ +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react"; +import type { ScheduledJobDto } from "@aaif/goose-sdk"; +import { + LOCAL_SCHEDULES_QUERY_KEY, + LOCAL_SCHEDULES_REFETCH_INTERVAL_MS, + listLocalSchedules, +} from "@/features/automations/api/localSchedules"; +import { EmptyState } from "@/features/automations/ui/RunOutput"; +import { useLocaleFormatting } from "@/shared/i18n"; +import { Alert, AlertTitle, AlertDescription } from "@/shared/ui/alert"; +import { Button } from "@/shared/ui/button"; + +function scheduleSourceLabel(schedule: ScheduledJobDto): string { + const source = schedule.source.trim(); + const fileName = source.split(/[\\/]/).at(-1); + return fileName || source; +} + +function localErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.trim()) return error.message; + if (typeof error === "string" && error.trim()) return error; + return fallback; +} + +export function LocalSchedulesPanel() { + const { t } = useTranslation("automations"); + const { formatDate } = useLocaleFormatting(); + + function lastRunLabel(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? t("localSchedules.unknownLastRun") + : formatDate(date, { dateStyle: "medium", timeStyle: "short" }); + } + const schedulesQuery = useQuery({ + queryKey: LOCAL_SCHEDULES_QUERY_KEY, + queryFn: listLocalSchedules, + refetchInterval: LOCAL_SCHEDULES_REFETCH_INTERVAL_MS, + }); + const schedules = schedulesQuery.data ?? []; + + return ( +
+
+
+

+ {t("localSchedules.title")} +

+

+ {t("localSchedules.description")} +

+
+ +
+ + {schedulesQuery.isLoading ? ( +
+ ) : schedulesQuery.error && schedules.length === 0 ? ( + + + ) : ( + <> + {schedulesQuery.error ? ( +
+ {t("localSchedules.refreshError", { + error: localErrorMessage( + schedulesQuery.error, + t("localSchedules.loadErrorDescription"), + ), + })} +
+ ) : null} + {schedules.length === 0 ? ( + + ) : ( +
+ {schedules.map((schedule) => ( +
+
+
+ {schedule.id} +
+
+ {t("localSchedules.sourceLabel", { + name: scheduleSourceLabel(schedule), + })} +
+
+ {schedule.cron} + + {t( + schedule.paused + ? "localSchedules.paused" + : "localSchedules.active", + )} + + {schedule.currentlyRunning ? ( + {t("localSchedules.running")} + ) : null} + {schedule.lastRun ? ( + + {t("localSchedules.lastRun", { + time: lastRunLabel(schedule.lastRun), + })} + + ) : null} +
+
+
+ ))} +
+ )} + + )} +
+ ); +} diff --git a/src/features/settings/ui/__tests__/settingsSections.test.ts b/src/features/settings/ui/__tests__/settingsSections.test.ts index 4d0f0f0ea..0ff4ee224 100644 --- a/src/features/settings/ui/__tests__/settingsSections.test.ts +++ b/src/features/settings/ui/__tests__/settingsSections.test.ts @@ -12,6 +12,7 @@ import { const enabledCapabilities: ProfileCapabilityState = { agentTools: true, automations: true, + localSchedules: true, builderbot: true, doctor: true, feedback: true, diff --git a/src/shared/i18n/locales/en/automations.json b/src/shared/i18n/locales/en/automations.json index 7c6866261..0266b5b30 100644 --- a/src/shared/i18n/locales/en/automations.json +++ b/src/shared/i18n/locales/en/automations.json @@ -28,6 +28,28 @@ "error": "Failed to duplicate automation.", "unsupportedType": "This automation type cannot be duplicated." }, + "companyAutomations": { + "title": "Company-managed automations" + }, + "localSchedules": { + "title": "Goose schedules", + "description": "Schedules run locally through this Goose instance.", + "sourceLabel": "Recipe file: {{name}}", + "hubTitle": "Automations", + "hubDescription": "View your local Goose schedules and company-managed automations.", + "hubLocalOnlyDescription": "View schedules configured in your local Goose instance.", + "loading": "Loading Goose schedules", + "loadErrorTitle": "Could not load Goose schedules", + "loadErrorDescription": "The local scheduler may be unavailable or Goose may not be connected.", + "refreshError": "Could not refresh schedules: {{error}}. Showing the last loaded list.", + "emptyTitle": "No local schedules yet", + "emptyBody": "Schedules you create in Goose will appear here. Use the Goose CLI or another Goose client to schedule and configure recipes.", + "active": "Active", + "unknownLastRun": "Unknown", + "paused": "Paused", + "running": "Running", + "lastRun": "Last run: {{time}}" + }, "builder": { "blockedBody": "The last response wasn't a valid automation. Try rephrasing what you want.", "blockedTitle": "Automation rejected", diff --git a/src/shared/i18n/locales/es/automations.json b/src/shared/i18n/locales/es/automations.json index 770631a22..9bf9d4243 100644 --- a/src/shared/i18n/locales/es/automations.json +++ b/src/shared/i18n/locales/es/automations.json @@ -28,6 +28,28 @@ "error": "No se pudo duplicar la automatizacion.", "unsupportedType": "Este tipo de automatizacion no se puede duplicar." }, + "companyAutomations": { + "title": "Automatizaciones administradas por la empresa" + }, + "localSchedules": { + "title": "Programaciones de Goose", + "description": "Se ejecutan localmente en esta instancia de Goose.", + "sourceLabel": "Archivo de receta: {{name}}", + "hubTitle": "Automatizaciones", + "hubDescription": "Consulta tus programaciones locales de Goose y las automatizaciones administradas por la empresa.", + "hubLocalOnlyDescription": "Consulta las programaciones configuradas en tu instancia local de Goose.", + "loading": "Cargando programaciones de Goose", + "loadErrorTitle": "No se pudieron cargar las programaciones de Goose", + "loadErrorDescription": "El programador local no esta disponible o Goose no esta conectado.", + "refreshError": "No se pudieron actualizar las programaciones: {{error}}. Se muestra la ultima lista cargada.", + "emptyTitle": "Aun no hay programaciones locales", + "emptyBody": "Las programaciones que crees en Goose apareceran aqui. Usa la CLI de Goose u otro cliente de Goose para programar y configurar recetas.", + "active": "Activa", + "unknownLastRun": "Desconocida", + "paused": "En pausa", + "running": "En ejecucion", + "lastRun": "Ultima ejecucion: {{time}}" + }, "builder": { "blockedBody": "La última respuesta no fue una automatización válida. Intenta reformular lo que quieres.", "blockedTitle": "Automatizacion rechazada", diff --git a/src/shared/profile/buildProfile.test.ts b/src/shared/profile/buildProfile.test.ts index 3696232f8..f86e83ae5 100644 --- a/src/shared/profile/buildProfile.test.ts +++ b/src/shared/profile/buildProfile.test.ts @@ -19,6 +19,7 @@ describe("buildProfile", () => { authGate: false, agentTools: false, automations: false, + localSchedules: true, builderbot: false, byoKeyProviders: true, feedback: false, diff --git a/src/shared/profile/buildProfile.ts b/src/shared/profile/buildProfile.ts index bb1423ad5..2988d628a 100644 --- a/src/shared/profile/buildProfile.ts +++ b/src/shared/profile/buildProfile.ts @@ -2,6 +2,7 @@ export type BuildFeature = | "authGate" | "agentTools" | "automations" + | "localSchedules" | "builderbot" | "byoKeyProviders" | "feedback" @@ -29,6 +30,7 @@ function readBuildFeatures(): Record { import.meta.env.VITE_BUILDERBOT === "1", agentTools: import.meta.env.VITE_AGENT_TOOLS === "1", automations: import.meta.env.VITE_AUTOMATIONS === "1", + localSchedules: true, builderbot: import.meta.env.VITE_BUILDERBOT === "1", byoKeyProviders: import.meta.env.VITE_BYO_KEY_PROVIDERS !== "0", feedback: import.meta.env.VITE_FEEDBACK === "1", diff --git a/src/shared/profile/capabilities.test.ts b/src/shared/profile/capabilities.test.ts index 2ee3448e8..97712f94f 100644 --- a/src/shared/profile/capabilities.test.ts +++ b/src/shared/profile/capabilities.test.ts @@ -23,6 +23,7 @@ const enabledBuildFeatures: Record = { authGate: false, agentTools: true, automations: true, + localSchedules: true, builderbot: true, byoKeyProviders: false, feedback: true, @@ -74,6 +75,20 @@ describe("profile capabilities", () => { }); }); + it("keeps local schedules available in public builds independently of Block automation services", () => { + expect( + resolve({ + buildFeatures: { + ...enabledBuildFeatures, + automations: false, + localSchedules: true, + }, + kgooseConfigured: false, + kgooseConfigLoaded: true, + }), + ).toMatchObject({ automations: false, localSchedules: true }); + }); + it("enables builderbot only when runtime config, build feature, and experiment are enabled", () => { expect( resolve({ diff --git a/src/shared/profile/capabilities.ts b/src/shared/profile/capabilities.ts index d66a0069b..cc96a48e3 100644 --- a/src/shared/profile/capabilities.ts +++ b/src/shared/profile/capabilities.ts @@ -15,6 +15,7 @@ import type { RuntimeFeatureToggleKey } from "./runtimeFeatureToggles"; export type ProfileCapabilityId = | "automations" + | "localSchedules" | "builderbot" | "agentTools" | "telemetry" @@ -53,6 +54,10 @@ export type ProfileCapabilityRegistry = Record< >; export const PROFILE_CAPABILITY_REGISTRY: ProfileCapabilityRegistry = { + localSchedules: { + kind: "buildFeature", + feature: "localSchedules", + }, automations: { kind: "runtimeFeature", feature: "automations",