Files
paseo/packages/app/src/hooks/use-schedule-mutations.ts
T
Mohamed BoudraandGitHub bf5e3b47e7 Add a Schedules screen to manage recurring agents (#1246)
* feat: add Schedules screen to manage scheduled agents

Adds a Schedules section in the sidebar header (below Sessions) for viewing
and managing new-agent schedules: recurring jobs that spawn an agent on a
cron or interval cadence.

- Borderless table showing provider icon, model, cadence, next run, and
  status, with row actions to edit, pause/resume, run now, and delete.
- Create/edit sheet that reuses the agent provider/model/mode/working-
  directory pickers, plus an interval/cron cadence editor with presets,
  validation, and a UTC preview.
- Responsive: hover-revealed row actions on desktop, an always-visible
  action menu on mobile.
- Data layer over the existing schedule RPCs (list/create/update/pause/
  resume/delete/run-once) with optimistic pause, resume, and delete.

Scope is limited to new-agent schedules for now.

* Make the schedule form controls visually consistent

Merge the provider and model pickers into the single nested selector the
draft screen uses, render it as a full-width field, mute the placeholder,
proportion the cadence segmented controls, unify the cron preset chips,
and route the sheet scroll through the themed scrollbar.

* Address schedules review feedback

* Translate schedule hook client fallback errors

* Make schedules global and project scoped

* Stabilize project settings transport E2E

* Reset schedule model on cross-host project change

* Reject malformed cron step expressions

* Tighten schedules project query freshness

* refactor(schedules): clean up review feedback

* test(schedules): extract fake host setup

* refactor(schedules): share cron expression parsing

Use one structural cron parser for app previews and daemon cadence validation so the accepted grammar cannot drift between surfaces.

Remove the unused host-scoped schedules redirect while keeping the global schedules route.
2026-06-30 22:58:49 +02:00

278 lines
8.3 KiB
TypeScript

import { useCallback } from "react";
import {
useMutation,
useQueryClient,
type QueryClient,
type QueryKey,
} from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type {
CreateScheduleOptions,
DaemonClient,
UpdateScheduleOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
import type { FetchAggregatedSchedulesResult } from "@/schedules/aggregated-schedules";
import { useSessionStore } from "@/stores/session-store";
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
export type UpdateScheduleInput = Omit<UpdateScheduleOptions, "requestId">;
export interface UseScheduleMutationsResult {
createSchedule: (input: CreateScheduleInput) => Promise<void>;
updateSchedule: (input: UpdateScheduleInput) => Promise<void>;
pauseSchedule: (id: string) => Promise<void>;
resumeSchedule: (id: string) => Promise<void>;
deleteSchedule: (id: string) => Promise<void>;
runScheduleNow: (id: string) => Promise<void>;
isCreating: boolean;
isUpdating: boolean;
isPausing: boolean;
isResuming: boolean;
isDeleting: boolean;
isRunningNow: boolean;
}
interface ScheduleListSnapshot {
previous: Array<[QueryKey, FetchAggregatedSchedulesResult | undefined]>;
}
function requireClient(serverId: string, unavailableMessage: string): DaemonClient {
const client = useSessionStore.getState().sessions[serverId]?.client ?? null;
if (!client) {
throw new Error(unavailableMessage);
}
return client;
}
function snapshotSchedules(queryClient: QueryClient): ScheduleListSnapshot {
return {
previous: queryClient.getQueriesData<FetchAggregatedSchedulesResult>({
queryKey: schedulesQueryBaseKey,
}),
};
}
function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapshot): void {
for (const [queryKey, previous] of snapshot.previous) {
queryClient.setQueryData(queryKey, previous);
}
}
function updateScheduleSections(
queryClient: QueryClient,
updateSection: (
section: FetchAggregatedSchedulesResult["sections"][number],
) => FetchAggregatedSchedulesResult["sections"][number],
): void {
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
{ queryKey: schedulesQueryBaseKey },
(current) => {
if (!current) {
return current;
}
return { sections: current.sections.map(updateSection) };
},
);
}
function optimisticallySetStatus(
queryClient: QueryClient,
serverId: string,
id: string,
status: ScheduleSummary["status"],
): void {
const pausedAt = status === "paused" ? new Date().toISOString() : null;
updateScheduleSections(queryClient, (section) =>
section.serverId === serverId
? {
...section,
schedules: section.schedules.map((schedule) =>
schedule.id === id ? { ...schedule, status, pausedAt } : schedule,
),
}
: section,
);
}
function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void {
updateScheduleSections(queryClient, (section) =>
section.serverId === serverId
? {
...section,
schedules: section.schedules.filter((schedule) => schedule.id !== id),
}
: section,
);
}
export function useScheduleMutations({
serverId,
}: {
serverId: string;
}): UseScheduleMutationsResult {
const queryClient = useQueryClient();
const { t } = useTranslation();
const invalidate = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey });
}, [queryClient]);
const createMutation = useMutation({
mutationFn: async (input: CreateScheduleInput): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleCreate(input);
if (payload.error) {
throw new Error(payload.error);
}
},
onSettled: invalidate,
});
const updateMutation = useMutation({
mutationFn: async (input: UpdateScheduleInput): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleUpdate(input);
if (payload.error) {
throw new Error(payload.error);
}
},
onSettled: invalidate,
});
const pauseMutation = useMutation({
mutationFn: async (id: string): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.schedulePause({ id });
if (payload.error) {
throw new Error(payload.error);
}
},
onMutate: async (id): Promise<ScheduleListSnapshot> => {
await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey });
const snapshot = snapshotSchedules(queryClient);
optimisticallySetStatus(queryClient, serverId, id, "paused");
return snapshot;
},
onError: (_error, _id, context) => {
if (context) {
restoreSchedules(queryClient, context);
}
},
onSettled: invalidate,
});
const resumeMutation = useMutation({
mutationFn: async (id: string): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleResume({ id });
if (payload.error) {
throw new Error(payload.error);
}
},
onMutate: async (id): Promise<ScheduleListSnapshot> => {
await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey });
const snapshot = snapshotSchedules(queryClient);
optimisticallySetStatus(queryClient, serverId, id, "active");
return snapshot;
},
onError: (_error, _id, context) => {
if (context) {
restoreSchedules(queryClient, context);
}
},
onSettled: invalidate,
});
const deleteMutation = useMutation({
mutationFn: async (id: string): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleDelete({ id });
if (payload.error) {
throw new Error(payload.error);
}
},
onMutate: async (id): Promise<ScheduleListSnapshot> => {
await queryClient.cancelQueries({ queryKey: schedulesQueryBaseKey });
const snapshot = snapshotSchedules(queryClient);
optimisticallyRemove(queryClient, serverId, id);
return snapshot;
},
onError: (_error, _id, context) => {
if (context) {
restoreSchedules(queryClient, context);
}
},
onSettled: invalidate,
});
const runNowMutation = useMutation({
mutationFn: async (id: string): Promise<void> => {
const client = requireClient(serverId, t("common.errors.daemonClientUnavailable"));
const payload = await client.scheduleRunOnce({ id });
if (payload.error) {
throw new Error(payload.error);
}
},
onSettled: invalidate,
});
const createSchedule = useCallback(
async (input: CreateScheduleInput): Promise<void> => {
await createMutation.mutateAsync(input);
},
[createMutation],
);
const updateSchedule = useCallback(
async (input: UpdateScheduleInput): Promise<void> => {
await updateMutation.mutateAsync(input);
},
[updateMutation],
);
const pauseSchedule = useCallback(
async (id: string): Promise<void> => {
await pauseMutation.mutateAsync(id);
},
[pauseMutation],
);
const resumeSchedule = useCallback(
async (id: string): Promise<void> => {
await resumeMutation.mutateAsync(id);
},
[resumeMutation],
);
const deleteSchedule = useCallback(
async (id: string): Promise<void> => {
await deleteMutation.mutateAsync(id);
},
[deleteMutation],
);
const runScheduleNow = useCallback(
async (id: string): Promise<void> => {
await runNowMutation.mutateAsync(id);
},
[runNowMutation],
);
return {
createSchedule,
updateSchedule,
pauseSchedule,
resumeSchedule,
deleteSchedule,
runScheduleNow,
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isPausing: pauseMutation.isPending,
isResuming: resumeMutation.isPending,
isDeleting: deleteMutation.isPending,
isRunningNow: runNowMutation.isPending,
};
}