Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/plans/goose-schedules-in-berd.md
Original file line number Diff line number Diff line change
@@ -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()`.
33 changes: 24 additions & 9 deletions src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -4444,7 +4458,7 @@ export function AppShell({
(view: AppView) => {
guardAppNavigation(() => {
resetGlobalComposerTransition();
if (view === "automations" && !isAutomationsFeatureEnabled) {
if (view === "automations" && !isAutomationsSurfaceEnabled) {
setActiveView("home");
return;
}
Expand Down Expand Up @@ -4485,7 +4499,7 @@ export function AppShell({
guardAppNavigation,
resetGlobalComposerTransition,
setActiveSession,
isAutomationsFeatureEnabled,
isAutomationsSurfaceEnabled,
isBuilderbotSurfaceEnabled,
],
);
Expand Down Expand Up @@ -4566,7 +4580,7 @@ export function AppShell({
route: AutomationNavigationRoute,
options?: AppNavigationUpdateOptions,
) => {
if (!isAutomationsFeatureEnabled) {
if (!isAutomationsSurfaceEnabled) {
return;
}
guardAppNavigation(() => {
Expand All @@ -4577,7 +4591,7 @@ export function AppShell({
setActiveView("automations");
});
},
[guardAppNavigation, isAutomationsFeatureEnabled, setActiveSession],
[guardAppNavigation, isAutomationsSurfaceEnabled, setActiveSession],
);

const navigateBuilderbot = useCallback(
Expand Down Expand Up @@ -4643,7 +4657,7 @@ export function AppShell({
}

if (location.view === "automations") {
if (!isAutomationsFeatureEnabled) {
if (!isAutomationsSurfaceEnabled) {
setActiveSession(null);
setActiveView("home");
return;
Expand Down Expand Up @@ -4691,7 +4705,7 @@ export function AppShell({
},
[
expandSidebar,
isAutomationsFeatureEnabled,
isAutomationsSurfaceEnabled,
isBuilderbotSurfaceEnabled,
setActiveSession,
setChatActiveSession,
Expand Down Expand Up @@ -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}
Expand Down
10 changes: 8 additions & 2 deletions src/app/ui/AppShellContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface AppShellContentProps {
onLoggedOut?: (status: AuthStatus) => void;
isPreparingContent: boolean;
automationsEnabled: boolean;
companyAutomationsEnabled?: boolean;
builderbotEnabled: boolean;
renderedSession?: ChatSession;
homeSessionId: string | null;
Expand Down Expand Up @@ -133,6 +134,7 @@ export function AppShellContent({
authStatus,
isPreparingContent,
automationsEnabled,
companyAutomationsEnabled = automationsEnabled,
builderbotEnabled,
designSystemInspectorVisible,
onCloseDesignSystem,
Expand Down Expand Up @@ -196,7 +198,7 @@ export function AppShellContent({
targetLocation,
});

const openHomeAutomation = automationsEnabled
const openHomeAutomation = companyAutomationsEnabled
? (automationId: string) =>
onNavigateAutomations({
surface: "detail",
Expand All @@ -205,7 +207,7 @@ export function AppShellContent({
selectedRunKey: null,
})
: undefined;
const openHomeAutomations = automationsEnabled
const openHomeAutomations = companyAutomationsEnabled
? () => onNavigateAutomations({ surface: "overview" })
: undefined;
const setupRequiredContent = homeProviderSetupRequired ? (
Expand Down Expand Up @@ -239,6 +241,7 @@ export function AppShellContent({
const routeContent = renderRouteContent({
authStatus,
automationsEnabled,
companyAutomationsEnabled,
builderbotEnabled,
chatComposerHandoffActive,
chatComposerHandoffInProgress,
Expand Down Expand Up @@ -306,6 +309,7 @@ export function AppShellContent({
interface RenderRouteContentOptions {
authStatus?: AuthStatus;
automationsEnabled: boolean;
companyAutomationsEnabled?: boolean;
builderbotEnabled: boolean;
chatComposerHandoffActive: boolean;
chatComposerHandoffInProgress: boolean;
Expand Down Expand Up @@ -381,6 +385,7 @@ interface RenderRouteContentOptions {

function renderRouteContent({
automationsEnabled,
companyAutomationsEnabled = automationsEnabled,
builderbotEnabled,
chatComposerHandoffActive,
chatComposerHandoffInProgress,
Expand Down Expand Up @@ -456,6 +461,7 @@ function renderRouteContent({
case "automations":
return automationsEnabled ? (
<AutomationsWorkbench
companyAutomationsEnabled={companyAutomationsEnabled}
route={location.route}
onRouteChange={onNavigateAutomations}
onBreadcrumbLabelChange={onAutomationsBreadcrumbLabelChange}
Expand Down
4 changes: 3 additions & 1 deletion src/app/views/NavigationPanesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,9 @@ export function NavigationPanesView({
settingsSections={visibleSettingsSections}
showBottomMask={showBottomMask}
showTopMask={showTopMask}
showAutomationsSurface={capabilities.automations}
showAutomationsSurface={
capabilities.automations || capabilities.localSchedules
}
showBuilderbotSurface={capabilities.builderbot}
showSecondaryBottomMask={showSecondaryBottomMask}
width={width}
Expand Down
42 changes: 42 additions & 0 deletions src/features/automations/api/localSchedules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getClient } from "@/shared/api/acpConnection";
import { listLocalSchedules } from "./localSchedules";

const listSchedules = vi.hoisted(() => 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({});
});
});
11 changes: 11 additions & 0 deletions src/features/automations/api/localSchedules.ts
Original file line number Diff line number Diff line change
@@ -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<ScheduledJobDto[]> {
const client = await getClient();
const response = await client.goose.GooseUnstableSchedulesList({});
return response.jobs;
}
Loading
Loading