diff --git a/packages/app/e2e/schedules-edit-model-hydration.spec.ts b/packages/app/e2e/schedules-edit-model-hydration.spec.ts index 56d075be5..b9d1f6f68 100644 --- a/packages/app/e2e/schedules-edit-model-hydration.spec.ts +++ b/packages/app/e2e/schedules-edit-model-hydration.spec.ts @@ -76,7 +76,7 @@ test.describe("Schedules", () => { await page.goto(buildSchedulesRoute()); const row = page.getByTestId(`schedule-row-${scheduleId}`); await expect(row).toBeVisible({ timeout: 30_000 }); - await expect(row).toContainText("ten-second-stream"); + await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 }); await row.click(); await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 }); diff --git a/packages/app/e2e/schedules-project-target.spec.ts b/packages/app/e2e/schedules-project-target.spec.ts index a10f4c587..d78133957 100644 --- a/packages/app/e2e/schedules-project-target.spec.ts +++ b/packages/app/e2e/schedules-project-target.spec.ts @@ -5,7 +5,6 @@ import { buildFakeScheduleHostWorkspace, installFakeScheduleHost, } from "./helpers/schedule-fake-host"; -import { getServerId } from "./helpers/server-id"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { waitForSidebarHydration } from "./helpers/workspace-ui"; import { buildSchedulesRoute } from "../src/utils/host-routes"; @@ -82,9 +81,9 @@ test.describe("Schedules project target", () => { await page.getByRole("button", { name: "Schedules" }).click(); await expect(page).toHaveURL(/\/schedules$/); await expect(page).not.toHaveURL(/\/h\//); - await expect(page.getByTestId(`schedules-section-${getServerId()}`)).toBeVisible(); + await expect(page.getByTestId("schedules-empty")).toBeVisible(); - await page.getByRole("button", { name: "New schedule" }).click(); + await page.getByTestId("schedules-new").click(); await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0); @@ -127,10 +126,8 @@ test.describe("Schedules project target", () => { label: "Fake host", port: fakePort, }); - await expect(page.getByTestId(`schedules-section-${fakeHost.serverId}`)).toBeVisible({ - timeout: 30_000, - }); - await page.getByRole("button", { name: "New schedule" }).click(); + await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 }); + await page.getByTestId("schedules-new").click(); await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 }); await page.getByRole("button", { name: /select project/i }).click(); diff --git a/packages/app/src/components/hosts/host-filter.tsx b/packages/app/src/components/hosts/host-filter.tsx new file mode 100644 index 000000000..4793de90c --- /dev/null +++ b/packages/app/src/components/hosts/host-filter.tsx @@ -0,0 +1,118 @@ +import { useCallback, useMemo, useRef, useState, type ReactElement } from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { ChevronDown, Server } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { HostProfile } from "@/types/host-connection"; +import type { Theme } from "@/styles/theme"; +import { + ALL_HOSTS_OPTION_ID, + getHostPickerLabel, + HostPicker, + HostStatusDotSlot, +} from "@/components/hosts/host-picker"; + +const ThemedServer = withUnistyles(Server); +const ThemedChevronDown = withUnistyles(ChevronDown); +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +export interface HostFilterProps { + hosts: HostProfile[]; + selectedHost: string; + onSelectHost: (serverId: string) => void; + triggerTestID?: string; +} + +/** + * The "All hosts / " filter pill shared by the History and Schedules + * screens: an anchored HostPicker with `includeAllHost`, hidden by the caller + * when only one host exists. Copies the History layout exactly. + */ +export function HostFilter({ + hosts, + selectedHost, + onSelectHost, + triggerTestID, +}: HostFilterProps): ReactElement { + const [isFilterOpen, setIsFilterOpen] = useState(false); + const filterAnchorRef = useRef(null); + + const selectedHostLabel = useMemo( + () => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }), + [hosts, selectedHost], + ); + + const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []); + + const filterTriggerStyle = useCallback( + ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.filterTrigger, + Boolean(hovered) && styles.filterTriggerHovered, + pressed && styles.filterTriggerPressed, + ], + [], + ); + + return ( + + + + {selectedHost === ALL_HOSTS_OPTION_ID ? ( + + ) : ( + + )} + + {selectedHostLabel} + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + filterTriggerWrap: { + alignSelf: "flex-start", + }, + filterTrigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + alignSelf: "flex-start", + paddingVertical: theme.spacing[1.5], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + }, + filterTriggerHovered: { + backgroundColor: theme.colors.surface2, + }, + filterTriggerPressed: { + backgroundColor: theme.colors.surface3, + }, + filterTriggerText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, +})); diff --git a/packages/app/src/components/schedules/cadence-editor.tsx b/packages/app/src/components/schedules/cadence-editor.tsx index 96fe0b8df..79b98e8d3 100644 --- a/packages/app/src/components/schedules/cadence-editor.tsx +++ b/packages/app/src/components/schedules/cadence-editor.tsx @@ -4,7 +4,6 @@ import type { PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { SegmentedControl } from "@/components/ui/segmented-control"; -import { isWeb } from "@/constants/platform"; import { describeCron, everyMsToParts, @@ -200,7 +199,6 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) { value={intervalUnit} onValueChange={handleUnitChange} options={UNIT_OPTIONS} - style={styles.unitControl} testID="cadence-interval-unit" /> @@ -283,8 +281,6 @@ function CronPresetChip({ ); } -const MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo"; - const styles = StyleSheet.create((theme) => ({ container: { gap: theme.spacing[3], @@ -308,15 +304,10 @@ const styles = StyleSheet.create((theme) => ({ borderColor: theme.colors.border, fontSize: theme.fontSize.base, }, - // Both cadence segmented controls hug their options and stand at the form's - // field height, so the interval row reads as input + toggle rather than a - // full-width track with the controls floating inside it. + // The mode toggle hugs its options at the left rather than stretching to a + // full-width track; the interval row then reads as input + toggle. modeControl: { alignSelf: "flex-start", - height: 44, - }, - unitControl: { - height: 44, }, presetRow: { flexDirection: "row", @@ -337,9 +328,11 @@ const styles = StyleSheet.create((theme) => ({ chipHover: { backgroundColor: theme.colors.surface3, }, + // Selected preset reads as a chosen surface, not a second accent fill + // competing with the sheet's primary CTA. chipSelected: { - backgroundColor: theme.colors.accent, - borderColor: theme.colors.accent, + backgroundColor: theme.colors.surface3, + borderColor: theme.colors.borderAccent, }, chipLabel: { fontSize: theme.fontSize.xs, @@ -347,7 +340,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, }, chipLabelSelected: { - color: theme.colors.accentForeground, + color: theme.colors.foreground, }, cronInput: { minHeight: 44, @@ -358,7 +351,7 @@ const styles = StyleSheet.create((theme) => ({ borderWidth: 1, borderColor: theme.colors.border, fontSize: theme.fontSize.sm, - fontFamily: MONOSPACE_FONT, + fontFamily: theme.fontFamily.mono, }, preview: { fontSize: theme.fontSize.xs, @@ -370,6 +363,6 @@ const styles = StyleSheet.create((theme) => ({ }, error: { fontSize: theme.fontSize.xs, - color: theme.colors.destructive, + color: theme.colors.palette.red[300], }, })); diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 08607b1f2..5f797fcfb 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -24,7 +24,13 @@ import { getProviderIcon } from "@/components/provider-icons"; import { CadenceEditor } from "@/components/schedules/cadence-editor"; import { useScheduleMutations } from "@/hooks/use-schedule-mutations"; import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state"; +import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { useProjects } from "@/hooks/use-projects"; +import { + buildScheduleProjectTargets, + PROJECT_OPTION_PREFIX, + type ScheduleProjectTarget, +} from "@/schedules/schedule-project-targets"; import { validateCron } from "@/utils/schedule-format"; import { toErrorMessage } from "@/utils/error-messages"; import { shortenPath } from "@/utils/shorten-path"; @@ -32,7 +38,6 @@ import type { ProjectSummary } from "@/utils/projects"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 }; -const PROJECT_OPTION_PREFIX = "project:"; export interface ScheduleFormSheetProps { serverId?: string; @@ -42,15 +47,6 @@ export interface ScheduleFormSheetProps { schedule?: ScheduleSummary; } -interface ScheduleProjectTarget { - optionId: string; - serverId: string; - serverName: string; - projectKey: string; - projectName: string; - cwd: string; -} - interface ScheduleProjectOptions { targets: ScheduleProjectTarget[]; options: ComboboxOption[]; @@ -79,44 +75,19 @@ function buildInitialValues(schedule: ScheduleSummary | undefined): FormInitialV }; } -function buildProjectOptionId(serverId: string, projectKey: string): string { - return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`; -} - function buildProjectOptionTestId(optionId: string): string { const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, ""); return `schedule-project-option-${targetKey}`; } function buildScheduleProjectOptions(projects: readonly ProjectSummary[]): ScheduleProjectOptions { - const targets: ScheduleProjectTarget[] = []; - const targetByOptionId = new Map(); - const options: ComboboxOption[] = []; - - for (const project of projects) { - for (const host of project.hosts) { - const cwd = host.repoRoot.trim(); - if (!host.isOnline || !cwd) { - continue; - } - const target: ScheduleProjectTarget = { - optionId: buildProjectOptionId(host.serverId, project.projectKey), - serverId: host.serverId, - serverName: host.serverName, - projectKey: project.projectKey, - projectName: project.projectName, - cwd, - }; - targets.push(target); - targetByOptionId.set(target.optionId, target); - options.push({ - id: target.optionId, - label: target.projectName, - description: `${target.serverName} - ${shortenPath(cwd)}`, - }); - } - } - + const targets = buildScheduleProjectTargets(projects); + const targetByOptionId = new Map(targets.map((target) => [target.optionId, target])); + const options: ComboboxOption[] = targets.map((target) => ({ + id: target.optionId, + label: target.projectName, + description: `${target.serverName} - ${shortenPath(target.cwd)}`, + })); return { targets, options, targetByOptionId }; } @@ -153,6 +124,35 @@ function isSelectedModelValidForProviders(input: { return provider.modelSelection.rows.some((row) => row.modelId === selectedModel); } +function parseMaxRuns(raw: string): number | null { + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function canSubmitScheduleForm(input: { + isAgentTarget: boolean; + isEdit: boolean; + promptTrimmed: string; + cadenceError: string | null; + isSubmitting: boolean; + selectedModelIsValid: boolean; + hasWorkingDir: boolean; + hasSelectedProject: boolean; +}): boolean { + if (input.promptTrimmed.length === 0 || input.cadenceError !== null || input.isSubmitting) { + return false; + } + // Agent targets only edit name/prompt/cadence. New-agent edit accepts any + // non-empty stored cwd; create requires a matched project. + if (input.isAgentTarget) { + return true; + } + if (!input.selectedModelIsValid) { + return false; + } + return input.isEdit ? input.hasWorkingDir : input.hasSelectedProject; +} + export function ScheduleFormSheet({ serverId, visible, @@ -161,10 +161,26 @@ export function ScheduleFormSheet({ schedule, }: ScheduleFormSheetProps): ReactElement { const isEdit = mode === "edit"; - const editConfig = newAgentConfig(schedule); + // Agent-targeted schedules can only update name/prompt/cadence/maxRuns + // (service.ts rejects newAgentConfig for them), so the form drops the + // project/model/mode pickers and shows the target agent read-only instead. + const isAgentTarget = isEdit && schedule?.target.type === "agent"; const { projects } = useProjects(); + const { agents } = useAggregatedAgents({ includeArchived: true }); const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]); + const agentTargetLabel = useMemo(() => { + if (!schedule || schedule.target.type !== "agent") { + return null; + } + const { agentId } = schedule.target; + const agent = agents.find((entry) => entry.serverId === serverId && entry.id === agentId); + if (!agent) { + return "Agent unavailable"; + } + return agent.title?.trim() || "Untitled agent"; + }, [agents, schedule, serverId]); + const onlineServerIds = useMemo( () => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))), [projectOptions.targets], @@ -197,7 +213,9 @@ export function ScheduleFormSheet({ setProviderAndModelFromUser, clearProviderSelectionFromUser, setModeFromUser, + setSelectedServerId, setSelectedServerIdFromUser, + setWorkingDir, setWorkingDirFromUser, modeOptions, modelSelectorProviders, @@ -219,7 +237,10 @@ export function ScheduleFormSheet({ const handleSelectProject = useCallback( (target: ScheduleProjectTarget) => { - if (selectedProjectTarget && selectedProjectTarget.serverId !== target.serverId) { + // Compare against the current server, not the matched target: an unmatched + // stored cwd has no target but still lives on a host, and switching hosts + // must still clear a provider/model that may not exist on the new one. + if (selectedServerId && selectedServerId !== target.serverId) { clearProviderSelectionFromUser(); } setSelectedServerIdFromUser(target.serverId); @@ -227,7 +248,7 @@ export function ScheduleFormSheet({ }, [ clearProviderSelectionFromUser, - selectedProjectTarget, + selectedServerId, setSelectedServerIdFromUser, setWorkingDirFromUser, ], @@ -293,90 +314,127 @@ export function ScheduleFormSheet({ setCadence(schedule?.cadence ?? DEFAULT_CADENCE); setSubmitError(null); setFieldResetKey((key) => key + 1); + // The sheet stays mounted, and the form reducer's reset-on-close only + // clears user-modified flags — not the picker values — so a create opened + // after an edit would inherit that schedule's server/cwd (including a + // stale ghost path). Clear them so create always starts fresh; provider + // and model re-resolve from preferences. + if (!isEdit) { + setSelectedServerId(null); + setWorkingDir(""); + } } wasVisibleRef.current = visible; - }, [visible, schedule]); + }, [visible, schedule, isEdit, setSelectedServerId, setWorkingDir]); const promptTrimmed = prompt.trim(); + const trimmedWorkingDir = workingDir.trim(); const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null; const selectedModelIsValid = isSelectedModelValidForProviders({ providers: modelSelectorProviders, selectedProvider, selectedModel, }); - const canSubmit = - promptTrimmed.length > 0 && - selectedModelIsValid && - Boolean(selectedProjectTarget) && - cadenceError === null && - !isSubmitting; + const canSubmit = canSubmitScheduleForm({ + isAgentTarget, + isEdit, + promptTrimmed, + cadenceError, + isSubmitting, + selectedModelIsValid, + hasWorkingDir: trimmedWorkingDir.length > 0, + hasSelectedProject: Boolean(selectedProjectTarget), + }); - const handleSubmit = useCallback(async () => { - if (!selectedProvider || !selectedProjectTarget || !promptTrimmed) { - return; + // Agent target: the update RPC only accepts name/prompt/cadence/maxRuns. + const submitAgentTarget = useCallback(async (): Promise => { + if (!schedule) { + return false; } - setSubmitError(null); - try { - await persistFormPreferences(); - const parsedMaxRuns = Number.parseInt(maxRuns, 10); - const maxRunsValue = - Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null; + await updateSchedule({ + id: schedule.id, + name: name.trim() || null, + prompt: promptTrimmed, + cadence, + maxRuns: parseMaxRuns(maxRuns), + }); + return true; + }, [cadence, maxRuns, name, promptTrimmed, schedule, updateSchedule]); - if (isEdit && schedule) { - await updateSchedule({ - id: schedule.id, - name: name.trim() || null, - prompt: promptTrimmed, - cadence, - newAgentConfig: { - provider: selectedProvider, - model: selectedModel || null, - modeId: selectedMode || null, - cwd: selectedProjectTarget.cwd, - }, - maxRuns: maxRunsValue, - }); - } else { - await createSchedule({ - prompt: promptTrimmed, - name: name.trim() || undefined, - cadence, - target: { - type: "new-agent", - config: { - provider: selectedProvider, - cwd: selectedProjectTarget.cwd, - model: selectedModel || undefined, - modeId: selectedMode || undefined, - thinkingOptionId: selectedThinkingOptionId || undefined, - title: name.trim() || undefined, - }, - }, - ...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}), - }); - } - onClose(); - } catch (error) { - setSubmitError(toErrorMessage(error)); + // New-agent target: submit the current working directory. On edit an untouched + // picker leaves this as the stored cwd, so it round-trips unchanged. + const submitNewAgent = useCallback(async (): Promise => { + if (!selectedProvider || !trimmedWorkingDir) { + return false; } + await persistFormPreferences(); + const maxRunsValue = parseMaxRuns(maxRuns); + if (isEdit && schedule) { + await updateSchedule({ + id: schedule.id, + name: name.trim() || null, + prompt: promptTrimmed, + cadence, + newAgentConfig: { + provider: selectedProvider, + model: selectedModel || null, + modeId: selectedMode || null, + cwd: trimmedWorkingDir, + }, + maxRuns: maxRunsValue, + }); + return true; + } + await createSchedule({ + prompt: promptTrimmed, + name: name.trim() || undefined, + cadence, + target: { + type: "new-agent", + config: { + provider: selectedProvider, + cwd: trimmedWorkingDir, + model: selectedModel || undefined, + modeId: selectedMode || undefined, + thinkingOptionId: selectedThinkingOptionId || undefined, + title: name.trim() || undefined, + }, + }, + ...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}), + }); + return true; }, [ cadence, createSchedule, isEdit, maxRuns, name, - onClose, persistFormPreferences, promptTrimmed, schedule, selectedMode, selectedModel, - selectedProjectTarget, selectedProvider, selectedThinkingOptionId, + trimmedWorkingDir, updateSchedule, ]); + const handleSubmit = useCallback(async () => { + if (!promptTrimmed) { + return; + } + setSubmitError(null); + try { + const submitted = isAgentTarget ? await submitAgentTarget() : await submitNewAgent(); + if (submitted) { + onClose(); + } + } catch (error) { + setSubmitError(toErrorMessage(error)); + } + }, [isAgentTarget, onClose, promptTrimmed, submitAgentTarget, submitNewAgent]); + const handleSubmitPress = useCallback(() => { void handleSubmit(); }, [handleSubmit]); @@ -454,34 +512,53 @@ export function ScheduleFormSheet({ /> - - Project - - + {isAgentTarget ? ( + + Target + + + {agentTargetLabel} + + + Runs against this existing agent. + + ) : ( + <> + + Project + + - - Model - - + + Model + + - {modeOptions.length > 0 ? ( - - ) : null} + {modeOptions.length > 0 ? ( + + ) : null} + + )} Cadence @@ -504,10 +581,6 @@ export function ScheduleFormSheet({ Leave blank to run indefinitely - {editConfig === null && isEdit ? ( - This schedule does not target a new agent. - ) : null} - {submitError ? {submitError} : null} ); @@ -594,12 +667,15 @@ function ProjectField({ targetByOptionId, value, selectedTarget, + fallbackCwd, onSelect, }: { options: ComboboxOption[]; targetByOptionId: Map; value: string; selectedTarget: ScheduleProjectTarget | null; + /** Stored cwd for an edited schedule whose path matches no known project. */ + fallbackCwd: string; onSelect: (target: ScheduleProjectTarget) => void; }): ReactElement { const anchorRef = useRef(null); @@ -629,7 +705,13 @@ function ProjectField({ [open], ); - const displayValue = selectedTarget?.projectName ?? "Select project"; + // Honest hydration: a stored cwd that matches no known project shows the + // shortened path itself (not the blank "Select project"), and stays put until + // the user deliberately picks a project. + const storedPath = fallbackCwd.trim(); + const displayValue = + selectedTarget?.projectName ?? (storedPath ? shortenPath(storedPath) : "Select project"); + const isPlaceholder = !selectedTarget && !storedPath; const description = selectedTarget ? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}` : null; @@ -662,7 +744,7 @@ function ProjectField({ testID="schedule-project-trigger" > {displayValue} @@ -809,9 +891,20 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, }, error: { - color: theme.colors.destructive, + color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs, }, + readonlyField: { + flexDirection: "row", + alignItems: "center", + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + minHeight: 44, + }, selectTrigger: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx index a24eb8e25..a5b6b0c4e 100644 --- a/packages/app/src/components/schedules/schedule-row.tsx +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -15,7 +15,9 @@ import { isNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { settingsStyles } from "@/styles/settings"; import type { Theme } from "@/styles/theme"; +import type { ScheduleDerivedState } from "@/schedules/schedule-derivation"; import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format"; +import { formatTimeAgo } from "@/utils/time"; import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; // Themed lucide wrappers — module-scope so only the icon re-renders on theme @@ -53,43 +55,62 @@ export interface ScheduleRowActions { interface ScheduleRowProps extends ScheduleRowActions { schedule: ScheduleSummary; + /** Client-derived target line (agent title / project / shortened path). */ + targetLabel: string; + /** Provider glyph, resolved from the schedule config or the target agent. */ + provider: string | null; + /** Client-derived state — the single source for the badge and next-run copy. */ + state: ScheduleDerivedState; + /** Host name, rendered when the list spans more than one host. */ + serverName?: string; + /** True when only one host exists and the host name would be redundant. */ + singleHost?: boolean; pending?: ScheduleRowPending; isFirst: boolean; } -function resolveProvider(schedule: ScheduleSummary): string | null { - return schedule.target.type === "new-agent" ? schedule.target.config.provider : null; +function stateBadge(state: ScheduleDerivedState): { + label: string; + variant: "success" | "error" | "muted"; +} { + switch (state) { + case "active": + return { label: "Active", variant: "success" }; + case "paused": + return { label: "Paused", variant: "muted" }; + case "expired": + return { label: "Expired", variant: "muted" }; + case "finished": + return { label: "Finished", variant: "muted" }; + case "targetGone": + return { label: "Target gone", variant: "error" }; + } } -function resolveModelLabel(schedule: ScheduleSummary): string { - if (schedule.target.type === "new-agent" && schedule.target.config.model) { - return schedule.target.config.model; +// Meta reads left-to-right as identity → history → future: how often, when it +// was created, when it last ran, and (only while it can still run) when it runs +// next. Status lives on the badge, never repeated here. +function buildMeta( + schedule: ScheduleSummary, + state: ScheduleDerivedState, + serverName: string | undefined, + singleHost: boolean, +): string { + const parts = [ + formatCadence(schedule.cadence), + `Created ${formatTimeAgo(new Date(schedule.createdAt))}`, + schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run", + ]; + if (state === "active") { + const next = formatNextRun(schedule.nextRunAt); + if (next) { + parts.push(`Next run ${next}`); + } } - return "Default model"; -} - -function statusVariant(status: ScheduleSummary["status"]): "success" | "muted" { - return status === "active" ? "success" : "muted"; -} - -function statusLabel(status: ScheduleSummary["status"]): string { - if (status === "active") { - return "Active"; + if (serverName && !singleHost) { + parts.unshift(serverName); } - if (status === "paused") { - return "Paused"; - } - return "Completed"; -} - -function nextRunLabel(schedule: ScheduleSummary): string { - if (schedule.status === "paused") { - return "Paused"; - } - if (schedule.status === "completed") { - return "Completed"; - } - return formatNextRun(schedule.nextRunAt) || "—"; + return parts.join(" · "); } /** Small provider glyph. Reads the icon color off a StyleSheet object so the @@ -113,6 +134,11 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement */ export function ScheduleRow({ schedule, + targetLabel, + provider, + state, + serverName, + singleHost, pending, isFirst, onEdit, @@ -126,15 +152,10 @@ export function ScheduleRow({ const handlePointerEnter = useCallback(() => setIsHovered(true), []); const handlePointerLeave = useCallback(() => setIsHovered(false), []); - const provider = resolveProvider(schedule); const title = resolveScheduleTitle(schedule); - const meta = [ - resolveModelLabel(schedule), - formatCadence(schedule.cadence), - nextRunLabel(schedule), - ] - .filter(Boolean) - .join(" · "); + const badge = stateBadge(state); + const meta = buildMeta(schedule, state, serverName, singleHost ?? false); + const canRun = state === "active" || state === "paused"; const rowStyle = useCallback( ({ pressed }: PressableStateCallbackType) => [ @@ -168,6 +189,9 @@ export function ScheduleRow({ {title} + + {targetLabel} + {meta} @@ -175,12 +199,10 @@ export function ScheduleRow({ - + ): ReactElement { +}: Pick< + ScheduleRowProps, + "schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete" +> & { + canRun: boolean; +}): ReactElement { return ( ({ flex: 1, minWidth: 0, }, + target: { + marginTop: theme.spacing[1], + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, trailing: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/schedules/schedules-table.tsx b/packages/app/src/components/schedules/schedules-table.tsx index 3ab1ce96b..86b5bd52e 100644 --- a/packages/app/src/components/schedules/schedules-table.tsx +++ b/packages/app/src/components/schedules/schedules-table.tsx @@ -3,44 +3,48 @@ import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row"; import { useScheduleMutations } from "@/hooks/use-schedule-mutations"; +import type { AggregatedSchedule } from "@/hooks/use-schedules"; +import type { ScheduleDerivedState } from "@/schedules/schedule-derivation"; import { settingsStyles } from "@/styles/settings"; import { confirmDialog } from "@/utils/confirm-dialog"; import { resolveScheduleTitle } from "@/utils/schedule-format"; -import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; + +/** A schedule plus the client-derived fields the row renders. */ +export interface ScheduleRowView { + schedule: AggregatedSchedule; + targetLabel: string; + provider: string | null; + state: ScheduleDerivedState; + serverName: string; + /** True when only one host exists, so the host name is redundant in rows. */ + singleHost: boolean; +} interface SchedulesTableProps { - serverId: string; - schedules: ScheduleSummary[]; + rows: ScheduleRowView[]; /** * The form sheet is owned by the screen (it serves both create and edit and * shares the header's "New schedule" button), so the table delegates edit * upward rather than mounting a second sheet here. */ - onEditSchedule: (schedule: ScheduleSummary) => void; + onEditSchedule: (schedule: AggregatedSchedule) => void; } /** - * The schedules list: a single settings-style card of rows in a centered, - * width-constrained reading column, matching the projects list. Owns row-level - * actions (pause/resume/run/delete via the mutations hook + a destructive - * confirm for delete) and delegates editing to the parent. + * The schedules list: a single settings-style card of rows across every + * connected host, in a centered, width-constrained reading column matching the + * projects list. Rows own their host-scoped mutations (pause/resume/run/delete + * via the mutations hook + a destructive confirm) and delegate editing upward. */ -export function SchedulesTable({ - serverId, - schedules, - onEditSchedule, -}: SchedulesTableProps): ReactElement { - const mutations = useScheduleMutations({ serverId }); - +export function SchedulesTable({ rows, onEditSchedule }: SchedulesTableProps): ReactElement { return ( - {schedules.map((schedule, index) => ( + {rows.map((row, index) => ( ))} @@ -50,28 +54,26 @@ export function SchedulesTable({ } // --------------------------------------------------------------------------- -// Per-row wrapper owns local in-flight state and binds the table's mutation -// callbacks to this schedule. Local state keeps pending precise to the acting -// row even when several rows are acted on at once (the mutations hook exposes -// only a single global pending flag per action). +// Per-row wrapper owns local in-flight state and binds mutations to this +// schedule's host. Local state keeps pending precise to the acting row even +// when several rows are acted on at once (the mutations hook exposes only a +// single global pending flag per action). // --------------------------------------------------------------------------- -type ScheduleMutations = ReturnType; - const NO_PENDING: ScheduleRowPending = {}; function SchedulesTableRow({ - schedule, + row, isFirst, - mutations, onEditSchedule, }: { - schedule: ScheduleSummary; + row: ScheduleRowView; isFirst: boolean; - mutations: ScheduleMutations; - onEditSchedule: (schedule: ScheduleSummary) => void; + onEditSchedule: (schedule: AggregatedSchedule) => void; }): ReactElement { - const { id } = schedule; + const { schedule } = row; + const { id, serverId } = schedule; + const mutations = useScheduleMutations({ serverId }); const [pending, setPending] = useState(NO_PENDING); const runAction = useCallback( @@ -127,6 +129,11 @@ function SchedulesTableRow({ return ( ; @@ -60,11 +63,9 @@ function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapsh } } -function updateScheduleSections( +function updateSchedulesData( queryClient: QueryClient, - updateSection: ( - section: FetchAggregatedSchedulesResult["sections"][number], - ) => FetchAggregatedSchedulesResult["sections"][number], + updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[], ): void { queryClient.setQueriesData( { queryKey: schedulesQueryBaseKey }, @@ -72,7 +73,7 @@ function updateScheduleSections( if (!current) { return current; } - return { sections: current.sections.map(updateSection) }; + return { ...current, schedules: updateSchedules(current.schedules) }; }, ); } @@ -84,26 +85,18 @@ function optimisticallySetStatus( 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, + updateSchedulesData(queryClient, (schedules) => + schedules.map((schedule) => + schedule.serverId === serverId && schedule.id === id + ? { ...schedule, status, pausedAt } + : schedule, + ), ); } 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, + updateSchedulesData(queryClient, (schedules) => + schedules.filter((schedule) => !(schedule.serverId === serverId && schedule.id === id)), ); } diff --git a/packages/app/src/hooks/use-schedules.ts b/packages/app/src/hooks/use-schedules.ts index 66cbbcfef..d0b945850 100644 --- a/packages/app/src/hooks/use-schedules.ts +++ b/packages/app/src/hooks/use-schedules.ts @@ -1,23 +1,30 @@ import { useMemo, useSyncExternalStore } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { fetchAggregatedSchedules, + type AggregatedSchedule, + type ScheduleHostError, type ScheduleHostInput, - type ScheduleHostSection, } from "@/schedules/aggregated-schedules"; -export type { ScheduleHostSection } from "@/schedules/aggregated-schedules"; +export type { AggregatedSchedule, ScheduleHostError } from "@/schedules/aggregated-schedules"; export const schedulesQueryBaseKey = ["schedules"] as const; -export function schedulesQueryKey(hosts: readonly ScheduleHostInput[]) { - return [...schedulesQueryBaseKey, hosts.map((host) => host.serverId).join("|")] as const; +// Cache identity for the host set. The query also carries the runtime version +// (below) so it retries as connectivity changes and reliably fetches once a host +// comes online — even on a cold deep-link. The full-screen spinner flash that +// keying on the version used to cause is prevented by keepPreviousData plus the +// isInitialLoad(data === undefined) gate, not by dropping the version. +export function schedulesQueryKey(serverIds: readonly string[]) { + return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const; } export interface UseSchedulesResult { - sections: ScheduleHostSection[]; - isLoading: boolean; + schedules: AggregatedSchedule[]; + hostErrors: ScheduleHostError[]; + isInitialLoad: boolean; isError: boolean; error: Error | null; refetch: () => void; @@ -33,23 +40,21 @@ export function useSchedules(): UseSchedulesResult { () => runtime.getVersion(), ); const hostInputs = useMemo( - () => - hosts.map((host) => ({ - serverId: host.serverId, - serverName: host.label, - })), + () => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })), [hosts], ); const query = useQuery({ - queryKey: [...schedulesQueryKey(hostInputs), runtimeVersion] as const, + queryKey: [...schedulesQueryKey(hostInputs.map((host) => host.serverId)), runtimeVersion], queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }), staleTime: 5_000, + placeholderData: keepPreviousData, }); return { - sections: query.data?.sections ?? [], - isLoading: query.isLoading, + schedules: query.data?.schedules ?? [], + hostErrors: query.data?.hostErrors ?? [], + isInitialLoad: query.isLoading && query.data === undefined, isError: query.isError, error: query.error, refetch: () => { diff --git a/packages/app/src/schedules/aggregated-schedules.ts b/packages/app/src/schedules/aggregated-schedules.ts index fb4bbe0fb..bf94d7eca 100644 --- a/packages/app/src/schedules/aggregated-schedules.ts +++ b/packages/app/src/schedules/aggregated-schedules.ts @@ -1,8 +1,9 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; -import { isNewAgentSchedule } from "@/utils/schedule-format"; import { toErrorMessage } from "@/utils/error-messages"; +export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules"; + export interface ScheduleHostInput { serverId: string; serverName: string; @@ -17,65 +18,76 @@ export interface ScheduleRuntime { getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined; } -export interface ScheduleHostSection { +/** A schedule tagged with the host it came from, so the flat list can render a + * per-row host label and scope mutations without host sections. */ +export interface AggregatedSchedule extends ScheduleSummary { serverId: string; serverName: string; - isOnline: boolean; - schedules: ScheduleSummary[]; - error: string | null; } -export interface FetchAggregatedSchedulesInput { - hosts: ScheduleHostInput[]; - runtime: ScheduleRuntime; +export interface ScheduleHostError { + serverId: string; + serverName: string; + message: string; } export interface FetchAggregatedSchedulesResult { - sections: ScheduleHostSection[]; + schedules: AggregatedSchedule[]; + hostErrors: ScheduleHostError[]; } +export interface FetchAggregatedSchedulesInput { + hosts: readonly ScheduleHostInput[]; + runtime: ScheduleRuntime; +} + +/** + * Fetch schedules across connected hosts and merge them into one flat list. + * Connectivity is checked here at execution time (not pre-filtered by the hook) + * so the query — retried as the runtime version changes — reliably picks a host + * up the moment it comes online, including on a cold deep-link. + * + * Offline hosts are skipped. A connected host that fails contributes to + * `hostErrors` (surfaced as a banner) while the rest still render; only when + * every connected host fails do we throw so the screen shows a full error. + */ export async function fetchAggregatedSchedules( input: FetchAggregatedSchedulesInput, ): Promise { - const sections = await Promise.all( - input.hosts.map(async (host): Promise => { + const schedules: AggregatedSchedule[] = []; + const hostErrors: ScheduleHostError[] = []; + let connectedAttempts = 0; + + await Promise.all( + input.hosts.map(async (host) => { const snapshot = input.runtime.getSnapshot(host.serverId); const isOnline = snapshot?.connectionStatus === "online"; const client = input.runtime.getClient(host.serverId); - if (!client || !isOnline) { - return { - serverId: host.serverId, - serverName: host.serverName, - isOnline, - schedules: [], - error: null, - }; + return; } - + connectedAttempts += 1; try { const payload = await client.scheduleList(); if (payload.error) { throw new Error(payload.error); } - return { - serverId: host.serverId, - serverName: host.serverName, - isOnline, - schedules: payload.schedules.filter(isNewAgentSchedule), - error: null, - }; + for (const schedule of payload.schedules) { + schedules.push({ ...schedule, serverId: host.serverId, serverName: host.serverName }); + } } catch (error) { - return { + hostErrors.push({ serverId: host.serverId, serverName: host.serverName, - isOnline, - schedules: [], - error: toErrorMessage(error), - }; + message: toErrorMessage(error), + }); } }), ); - return { sections }; + if (connectedAttempts > 0 && schedules.length === 0 && hostErrors.length === connectedAttempts) { + throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE); + } + + return { schedules, hostErrors }; } diff --git a/packages/app/src/schedules/schedule-derivation.test.ts b/packages/app/src/schedules/schedule-derivation.test.ts new file mode 100644 index 000000000..800b92cdb --- /dev/null +++ b/packages/app/src/schedules/schedule-derivation.test.ts @@ -0,0 +1,133 @@ +import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; +import { describe, expect, it } from "vitest"; +import { resolveSchedule, scheduleBucket, type ScheduleTargetAgent } from "./schedule-derivation"; + +const NOW = Date.parse("2026-07-02T00:00:00.000Z"); +const AGENT_ID = "00000000-0000-4000-8000-000000000000"; + +function makeSchedule(overrides: Partial): ScheduleSummary { + return { + id: "schedule-1", + name: "Nightly", + prompt: "Run the task", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } }, + status: "active", + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + nextRunAt: "2026-07-02T01:00:00.000Z", + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + ...overrides, + }; +} + +function resolve( + schedule: ScheduleSummary, + options?: { + agents?: Array<[string, ScheduleTargetAgent]>; + projects?: Array<[string, string]>; + agentDataLoaded?: boolean; + }, +) { + return resolveSchedule({ + schedule, + serverId: "host-1", + now: NOW, + agentsByKey: new Map(options?.agents ?? []), + projectNameByCwd: new Map(options?.projects ?? []), + agentDataLoaded: options?.agentDataLoaded ?? true, + }); +} + +describe("resolveSchedule state", () => { + it("keeps active and paused schedules runnable", () => { + expect(resolve(makeSchedule({ status: "active" })).state).toBe("active"); + expect(resolve(makeSchedule({ status: "paused" })).state).toBe("paused"); + expect(scheduleBucket("active")).toBe("runnable"); + expect(scheduleBucket("paused")).toBe("runnable"); + }); + + it("treats a past expiresAt as expired regardless of status", () => { + const result = resolve( + makeSchedule({ status: "active", expiresAt: "2026-07-01T00:00:00.000Z" }), + ); + expect(result.state).toBe("expired"); + expect(result.bucket).toBe("ended"); + }); + + it("ignores an unparseable expiresAt", () => { + expect(resolve(makeSchedule({ expiresAt: "not-a-date" })).state).toBe("active"); + }); + + it("derives finished only from completed-and-not-expired", () => { + expect(resolve(makeSchedule({ status: "completed" })).state).toBe("finished"); + expect( + resolve(makeSchedule({ status: "completed", expiresAt: "2026-07-01T00:00:00.000Z" })).state, + ).toBe("expired"); + }); + + it("marks an agent target gone when the client has no such agent", () => { + const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } }); + expect(resolve(schedule).state).toBe("targetGone"); + expect(resolve(schedule).bucket).toBe("ended"); + }); + + it("does not claim gone before the agent directory has loaded", () => { + const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } }); + expect(resolve(schedule, { agentDataLoaded: false }).state).toBe("active"); + }); + + it("prefers target-gone over the raw paused/completed status for a live agent target", () => { + const paused = makeSchedule({ + status: "paused", + target: { type: "agent", agentId: AGENT_ID }, + }); + expect(resolve(paused).state).toBe("targetGone"); + }); + + it("never claims a new-agent cwd is gone", () => { + expect(resolve(makeSchedule({ status: "active" })).state).toBe("active"); + }); +}); + +describe("resolveSchedule target line", () => { + it("names an agent target by its client title and provider", () => { + const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } }); + const result = resolve(schedule, { + agents: [[`host-1:${AGENT_ID}`, { title: "Fix build", provider: "claude" }]], + }); + expect(result.target).toEqual({ label: "Fix build", provider: "claude" }); + expect(result.state).toBe("active"); + }); + + it("falls back to Untitled agent when the agent has no title", () => { + const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } }); + const result = resolve(schedule, { + agents: [[`host-1:${AGENT_ID}`, { title: " ", provider: "codex" }]], + }); + expect(result.target.label).toBe("Untitled agent"); + }); + + it("labels a gone agent target as unavailable with no glyph", () => { + const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } }); + expect(resolve(schedule).target).toEqual({ label: "Agent unavailable", provider: null }); + }); + + it("names a new-agent cwd by matched project, else the shortened path", () => { + const matched = makeSchedule({ + target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } }, + }); + expect(resolve(matched, { projects: [["host-1:/tmp/project", "My Project"]] }).target).toEqual({ + label: "My Project", + provider: "codex", + }); + + const unmatched = makeSchedule({ + target: { type: "new-agent", config: { provider: "codex", cwd: "/Users/alex/work/api" } }, + }); + expect(resolve(unmatched).target).toEqual({ label: "~/work/api", provider: "codex" }); + }); +}); diff --git a/packages/app/src/schedules/schedule-derivation.ts b/packages/app/src/schedules/schedule-derivation.ts new file mode 100644 index 000000000..e17609ec7 --- /dev/null +++ b/packages/app/src/schedules/schedule-derivation.ts @@ -0,0 +1,109 @@ +import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; +import { describeScheduleCwd } from "@/schedules/schedule-project-targets"; + +// Derived from existing fields only — no new protocol state. "active"/"paused" +// mirror the stored status; the rest are computed truths the daemon does not +// spell out in a single field. +export type ScheduleDerivedState = "active" | "paused" | "expired" | "finished" | "targetGone"; + +export type ScheduleBucket = "runnable" | "ended"; + +export interface ScheduleTargetAgent { + title: string | null; + provider: string | null; +} + +export interface ScheduleTargetResolution { + /** The target line: agent title, project name, or the shortened cwd. */ + label: string; + /** Provider glyph for the row, when known. */ + provider: string | null; +} + +export interface ResolvedSchedule { + state: ScheduleDerivedState; + bucket: ScheduleBucket; + target: ScheduleTargetResolution; +} + +export interface ResolveScheduleInput { + schedule: ScheduleSummary; + serverId: string; + now: number; + /** Client agent directory keyed by `${serverId}:${agentId}`. */ + agentsByKey: ReadonlyMap; + /** Known project roots keyed by `${serverId}:${cwd}`. */ + projectNameByCwd: ReadonlyMap; + /** + * Whether the agent directory has finished its first load. While false we do + * not claim an agent target is gone — absence would just be a cold cache. + */ + agentDataLoaded: boolean; +} + +function agentKey(serverId: string, agentId: string): string { + return `${serverId}:${agentId}`; +} + +function isExpired(schedule: ScheduleSummary, now: number): boolean { + if (!schedule.expiresAt) { + return false; + } + const expiresAt = Date.parse(schedule.expiresAt); + return Number.isFinite(expiresAt) && expiresAt <= now; +} + +function isAgentTargetGone(input: ResolveScheduleInput): boolean { + const { schedule, serverId, agentsByKey, agentDataLoaded } = input; + if (schedule.target.type !== "agent" || !agentDataLoaded) { + return false; + } + return !agentsByKey.has(agentKey(serverId, schedule.target.agentId)); +} + +function resolveTarget(input: ResolveScheduleInput): ScheduleTargetResolution { + const { schedule, serverId, agentsByKey, projectNameByCwd } = input; + if (schedule.target.type === "agent") { + const agent = agentsByKey.get(agentKey(serverId, schedule.target.agentId)); + if (agent) { + return { label: agent.title?.trim() || "Untitled agent", provider: agent.provider }; + } + return { label: "Agent unavailable", provider: null }; + } + return { + label: describeScheduleCwd({ serverId, cwd: schedule.target.config.cwd, projectNameByCwd }), + provider: schedule.target.config.provider, + }; +} + +// One badge, one truth. Order matters: expiry and a missing target are more +// informative than the raw "completed"/"paused" status, so they win. +function deriveState(input: ResolveScheduleInput): ScheduleDerivedState { + const { schedule, now } = input; + if (isExpired(schedule, now)) { + return "expired"; + } + if (isAgentTargetGone(input)) { + return "targetGone"; + } + if (schedule.status === "completed") { + return "finished"; + } + if (schedule.status === "paused") { + return "paused"; + } + return "active"; +} + +export function scheduleBucket(state: ScheduleDerivedState): ScheduleBucket { + return state === "active" || state === "paused" ? "runnable" : "ended"; +} + +export function resolveSchedule(input: ResolveScheduleInput): ResolvedSchedule { + const state = deriveState(input); + return { + state, + bucket: scheduleBucket(state), + target: resolveTarget(input), + }; +} diff --git a/packages/app/src/schedules/schedule-project-targets.test.ts b/packages/app/src/schedules/schedule-project-targets.test.ts new file mode 100644 index 000000000..c4c7d500c --- /dev/null +++ b/packages/app/src/schedules/schedule-project-targets.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import type { ProjectSummary } from "@/utils/projects"; +import { + buildProjectNameByCwd, + buildScheduleProjectTargets, + describeScheduleCwd, +} from "./schedule-project-targets"; + +function makeProject(overrides: Partial): ProjectSummary { + return { + projectKey: "proj", + projectName: "Project", + hosts: [], + totalWorkspaceCount: 0, + hostCount: 0, + onlineHostCount: 0, + ...overrides, + }; +} + +function makeHost(overrides: Partial) { + return { + serverId: "host-1", + serverName: "Host 1", + isOnline: true, + repoRoot: "/tmp/project", + workspaceCount: 0, + workspaces: [], + ...overrides, + }; +} + +describe("buildScheduleProjectTargets", () => { + it("emits one target per online host with a repo root", () => { + const targets = buildScheduleProjectTargets([ + makeProject({ + projectName: "Alpha", + hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })], + }), + ]); + expect(targets).toHaveLength(2); + expect(targets[0]).toMatchObject({ + serverId: "host-1", + cwd: "/tmp/alpha", + projectName: "Alpha", + }); + }); + + it("skips offline hosts and blank repo roots", () => { + const targets = buildScheduleProjectTargets([ + makeProject({ + hosts: [makeHost({ isOnline: false }), makeHost({ serverId: "host-3", repoRoot: " " })], + }), + ]); + expect(targets).toHaveLength(0); + }); +}); + +describe("describeScheduleCwd", () => { + it("prefers a matched project name and shortens unmatched paths", () => { + const byCwd = buildProjectNameByCwd( + buildScheduleProjectTargets([ + makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }), + ]), + ); + expect( + describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }), + ).toBe("Alpha"); + expect( + describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }), + ).toBe("~/api"); + }); +}); diff --git a/packages/app/src/schedules/schedule-project-targets.ts b/packages/app/src/schedules/schedule-project-targets.ts new file mode 100644 index 000000000..ee4c8c2cc --- /dev/null +++ b/packages/app/src/schedules/schedule-project-targets.ts @@ -0,0 +1,75 @@ +import type { ProjectSummary } from "@/utils/projects"; +import { shortenPath } from "@/utils/shorten-path"; + +export const PROJECT_OPTION_PREFIX = "project:"; + +export interface ScheduleProjectTarget { + optionId: string; + serverId: string; + serverName: string; + projectKey: string; + projectName: string; + cwd: string; +} + +export function buildProjectOptionId(serverId: string, projectKey: string): string { + return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`; +} + +/** + * The project roots the schedule form can target: one per online host of each + * project, keyed by (serverId, cwd). The schedules list reuses this set to name + * a schedule's stored cwd; the two surfaces must agree on what "a project" is. + */ +export function buildScheduleProjectTargets( + projects: readonly ProjectSummary[], +): ScheduleProjectTarget[] { + const targets: ScheduleProjectTarget[] = []; + for (const project of projects) { + for (const host of project.hosts) { + const cwd = host.repoRoot.trim(); + if (!host.isOnline || !cwd) { + continue; + } + targets.push({ + optionId: buildProjectOptionId(host.serverId, project.projectKey), + serverId: host.serverId, + serverName: host.serverName, + projectKey: project.projectKey, + projectName: project.projectName, + cwd, + }); + } + } + return targets; +} + +function projectNameKey(serverId: string, cwd: string): string { + return `${serverId}:${cwd.trim()}`; +} + +/** Map (serverId, cwd) -> project name for naming a schedule's stored cwd. */ +export function buildProjectNameByCwd( + targets: readonly ScheduleProjectTarget[], +): Map { + const byCwd = new Map(); + for (const target of targets) { + byCwd.set(projectNameKey(target.serverId, target.cwd), target.projectName); + } + return byCwd; +} + +/** + * Name a stored cwd for display: the matching project name when the client + * knows this root on this host, otherwise the shortened path itself. Never + * blank, never a claim the client cannot back up. + */ +export function describeScheduleCwd(input: { + serverId: string; + cwd: string; + projectNameByCwd: ReadonlyMap; +}): string { + return ( + input.projectNameByCwd.get(projectNameKey(input.serverId, input.cwd)) ?? shortenPath(input.cwd) + ); +} diff --git a/packages/app/src/screens/schedules-screen.tsx b/packages/app/src/screens/schedules-screen.tsx index 61de8e98d..a750610a2 100644 --- a/packages/app/src/screens/schedules-screen.tsx +++ b/packages/app/src/screens/schedules-screen.tsx @@ -1,14 +1,40 @@ -import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactElement, +} from "react"; import { ScrollView, Text, View } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import { Plus } from "lucide-react-native"; import { StyleSheet } from "react-native-unistyles"; import { MenuHeader } from "@/components/headers/menu-header"; +import { HostFilter } from "@/components/hosts/host-filter"; +import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker"; import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet"; -import { SchedulesTable } from "@/components/schedules/schedules-table"; +import { SchedulesTable, type ScheduleRowView } from "@/components/schedules/schedules-table"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import { useSchedules, type ScheduleHostSection } from "@/hooks/use-schedules"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; +import { useProjects } from "@/hooks/use-projects"; +import { + useSchedules, + type AggregatedSchedule, + type ScheduleHostError, +} from "@/hooks/use-schedules"; +import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; +import { + resolveSchedule, + type ScheduleBucket, + type ScheduleTargetAgent, +} from "@/schedules/schedule-derivation"; +import { + buildProjectNameByCwd, + buildScheduleProjectTargets, +} from "@/schedules/schedule-project-targets"; import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; type FormState = @@ -16,6 +42,11 @@ type FormState = | { mode: "create" } | { mode: "edit"; serverId: string; schedule: ScheduleSummary }; +const STATUS_FILTER_OPTIONS: { value: ScheduleBucket; label: string; testID: string }[] = [ + { value: "runnable", label: "Active", testID: "schedules-filter-active" }, + { value: "ended", label: "Ended", testID: "schedules-filter-ended" }, +]; + export function SchedulesScreen(): ReactElement { const isFocused = useIsFocused(); @@ -27,20 +58,101 @@ export function SchedulesScreen(): ReactElement { } function SchedulesScreenContent(): ReactElement { - const { sections, isLoading, isError, error, refetch } = useSchedules(); + const { schedules, hostErrors, isInitialLoad, isError, refetch } = useSchedules(); + const { agents } = useAggregatedAgents({ includeArchived: true }); + const { projects } = useProjects(); + const hosts = useHosts(); + const runtime = getHostRuntimeStore(); + const runtimeVersion = useSyncExternalStore( + (onStoreChange) => runtime.subscribeAll(onStoreChange), + () => runtime.getVersion(), + () => runtime.getVersion(), + ); + + // Per-host agent-directory readiness from the runtime, not the aggregate agent + // flag: the aggregate `isInitialLoad` flips false as soon as *any* host has + // agents, so a still-loading host would falsely mark its agent-target + // schedules "gone". `hasEverLoadedAgentDirectory` is true only once that + // host's directory has loaded at least once. + const agentDirReadyHosts = useMemo(() => { + void runtimeVersion; + const ready = new Set(); + for (const host of hosts) { + if (runtime.getSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) { + ready.add(host.serverId); + } + } + return ready; + }, [hosts, runtime, runtimeVersion]); + const [form, setForm] = useState({ mode: "closed" }); + const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID); + const [statusFilter, setStatusFilter] = useState("runnable"); - const openCreate = useCallback(() => { - setForm({ mode: "create" }); - }, []); + useEffect(() => { + if ( + selectedHost !== ALL_HOSTS_OPTION_ID && + !hosts.some((host) => host.serverId === selectedHost) + ) { + setSelectedHost(ALL_HOSTS_OPTION_ID); + } + }, [hosts, selectedHost]); - const openEdit = useCallback((serverId: string, schedule: ScheduleSummary) => { - setForm({ mode: "edit", serverId, schedule }); + const openCreate = useCallback(() => setForm({ mode: "create" }), []); + const openEdit = useCallback((schedule: AggregatedSchedule) => { + setForm({ mode: "edit", serverId: schedule.serverId, schedule }); }, []); + const closeForm = useCallback(() => setForm({ mode: "closed" }), []); - const closeForm = useCallback(() => { - setForm({ mode: "closed" }); - }, []); + const agentsByKey = useMemo(() => { + const map = new Map(); + for (const agent of agents) { + map.set(`${agent.serverId}:${agent.id}`, { title: agent.title, provider: agent.provider }); + } + return map; + }, [agents]); + + const projectNameByCwd = useMemo( + () => buildProjectNameByCwd(buildScheduleProjectTargets(projects)), + [projects], + ); + + // Resolve every schedule's derived state and target line once, then partition + // by the host and status filters. Sorted newest-first for a stable order + // across hosts. + const resolvedRows = useMemo(() => { + const now = Date.now(); + return schedules.map((schedule) => ({ + schedule, + resolved: resolveSchedule({ + schedule, + serverId: schedule.serverId, + now, + agentsByKey, + projectNameByCwd, + agentDataLoaded: agentDirReadyHosts.has(schedule.serverId), + }), + })); + }, [schedules, agentsByKey, projectNameByCwd, agentDirReadyHosts]); + + const visibleRows = useMemo(() => { + const singleHost = hosts.length <= 1; + return resolvedRows + .filter( + ({ schedule, resolved }) => + (selectedHost === ALL_HOSTS_OPTION_ID || schedule.serverId === selectedHost) && + resolved.bucket === statusFilter, + ) + .sort((a, b) => Date.parse(b.schedule.createdAt) - Date.parse(a.schedule.createdAt)) + .map(({ schedule, resolved }) => ({ + schedule, + targetLabel: resolved.target.label, + provider: resolved.target.provider, + state: resolved.state, + serverName: schedule.serverName, + singleHost, + })); + }, [resolvedRows, selectedHost, statusFilter, hosts.length]); const headerAction = useMemo( () => ( @@ -51,14 +163,24 @@ function SchedulesScreenContent(): ReactElement { [openCreate], ); + const showLoadError = isError && schedules.length === 0; + const showHostFilter = hosts.length > 1; + return ( - 0} + isInitialLoad={isInitialLoad} + showLoadError={showLoadError} + statusFilter={statusFilter} + onStatusFilterChange={setStatusFilter} + showHostFilter={showHostFilter} + hosts={hosts} + selectedHost={selectedHost} + onSelectHost={setSelectedHost} onRetry={refetch} onCreate={openCreate} onEdit={openEdit} @@ -74,24 +196,38 @@ function SchedulesScreenContent(): ReactElement { ); } -function SchedulesBody({ - sections, - isLoading, - isError, - error, +function SchedulesScreenBody({ + rows, + hostErrors, + hasSchedules, + isInitialLoad, + showLoadError, + statusFilter, + onStatusFilterChange, + showHostFilter, + hosts, + selectedHost, + onSelectHost, onRetry, onCreate, onEdit, }: { - sections: ScheduleHostSection[]; - isLoading: boolean; - isError: boolean; - error: Error | null; + rows: ScheduleRowView[]; + hostErrors: ScheduleHostError[]; + hasSchedules: boolean; + isInitialLoad: boolean; + showLoadError: boolean; + statusFilter: ScheduleBucket; + onStatusFilterChange: (value: ScheduleBucket) => void; + showHostFilter: boolean; + hosts: ReturnType; + selectedHost: string; + onSelectHost: (serverId: string) => void; onRetry: () => void; onCreate: () => void; - onEdit: (serverId: string, schedule: ScheduleSummary) => void; + onEdit: (schedule: AggregatedSchedule) => void; }): ReactElement { - if (isLoading) { + if (isInitialLoad) { return ( @@ -99,85 +235,95 @@ function SchedulesBody({ ); } - if (isError) { + if (showLoadError) { return ( - {error?.message ?? "Could not load schedules"} + Unable to load schedules ); } - if (sections.length === 0) { + if (!hasSchedules) { return ( - + + {hostErrors.length > 0 ? : null} No schedules yet - ); } - return ( - - {sections.map((section) => ( - - ))} - - ); -} - -function ScheduleHostSectionView({ - section, - onEdit, -}: { - section: ScheduleHostSection; - onEdit: (serverId: string, schedule: ScheduleSummary) => void; -}): ReactElement { - const handleEdit = useCallback( - (schedule: ScheduleSummary) => { - onEdit(section.serverId, schedule); - }, - [onEdit, section.serverId], - ); - const emptyMessage = section.isOnline ? "No schedules" : "Host offline"; + const emptyFilterText = statusFilter === "ended" ? "No ended schedules" : "No active schedules"; return ( - - - {section.serverName} - - {section.error ? {section.error} : null} - {section.schedules.length > 0 ? ( - + + {showHostFilter ? ( + + ) : null} + - ) : null} - {section.schedules.length === 0 && !section.error ? ( - - {emptyMessage} - - ) : null} + + + {hostErrors.length > 0 ? : null} + {rows.length > 0 ? ( + + ) : ( + + {emptyFilterText} + + )} + ); } +function ScheduleHostErrorsBanner({ errors }: { errors: ScheduleHostError[] }): ReactElement { + return ( + + + {errors.map((error) => ( + + {`${error.serverName}: Could not load schedules`} + + ))} + + + ); +} + +const CONTENT_MAX_WIDTH = 720; + const styles = StyleSheet.create((theme) => ({ container: { flex: 1, backgroundColor: theme.colors.surface0, }, + body: { + flex: 1, + minHeight: 0, + }, centered: { flex: 1, justifyContent: "center", @@ -185,43 +331,52 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[6], padding: theme.spacing[6], }, + filterRow: { + width: "100%", + maxWidth: CONTENT_MAX_WIDTH, + alignSelf: "center", + flexDirection: "row", + alignItems: "center", + flexWrap: "wrap", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + paddingTop: theme.spacing[4], + }, scroll: { flex: 1, minHeight: 0, }, scrollContent: { - gap: theme.spacing[4], + gap: theme.spacing[3], paddingTop: theme.spacing[4], paddingBottom: theme.spacing[6], }, - section: { - gap: theme.spacing[2], - }, - sectionTitle: { + errorsBannerWrap: { width: "100%", - maxWidth: 720, + maxWidth: CONTENT_MAX_WIDTH, alignSelf: "center", paddingHorizontal: theme.spacing[4], - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, }, - sectionError: { + errorsBanner: { + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + padding: theme.spacing[3], + gap: theme.spacing[1], + }, + errorsBannerText: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.xs, + }, + filterEmpty: { width: "100%", - maxWidth: 720, + maxWidth: CONTENT_MAX_WIDTH, alignSelf: "center", paddingHorizontal: theme.spacing[4], - color: theme.colors.destructive, - fontSize: theme.fontSize.sm, + paddingVertical: theme.spacing[6], + alignItems: "center", }, - sectionEmpty: { - width: "100%", - maxWidth: 720, - alignSelf: "center", - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - }, - sectionEmptyText: { + filterEmptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, diff --git a/packages/app/src/screens/sessions-screen.tsx b/packages/app/src/screens/sessions-screen.tsx index f205fb928..89ecfd431 100644 --- a/packages/app/src/screens/sessions-screen.tsx +++ b/packages/app/src/screens/sessions-screen.tsx @@ -1,23 +1,18 @@ -import { useMemo, useState, useCallback, useEffect, useRef } from "react"; -import { Pressable, type PressableStateCallbackType, View, Text } from "react-native"; +import { useMemo, useState, useCallback, useEffect } from "react"; +import { View, Text } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ChevronDown, ChevronLeft, Server } from "lucide-react-native"; +import { ChevronLeft } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { MenuHeader } from "@/components/headers/menu-header"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { AgentList } from "@/components/agent-list"; -import { HostStatusDotSlot } from "@/components/hosts/host-picker"; -import { - ALL_HOSTS_OPTION_ID, - getHostPickerLabel, - HostPicker, -} from "@/components/hosts/host-picker"; +import { HostFilter } from "@/components/hosts/host-filter"; +import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker"; import { useAgentHistory } from "@/hooks/use-agent-history"; import { useHosts } from "@/runtime/host-runtime"; -import { type HostProfile } from "@/types/host-connection"; import { buildOpenProjectRoute } from "@/utils/host-routes"; export function SessionsScreen() { @@ -30,71 +25,6 @@ export function SessionsScreen() { return ; } -function SessionsHostFilter({ - hosts, - selectedHost, - onSelectHost, -}: { - hosts: HostProfile[]; - selectedHost: string; - onSelectHost: (serverId: string) => void; -}) { - const { theme } = useUnistyles(); - const [isFilterOpen, setIsFilterOpen] = useState(false); - const filterAnchorRef = useRef(null); - - const selectedHostLabel = useMemo( - () => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }), - [hosts, selectedHost], - ); - - const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []); - - const filterTriggerStyle = useCallback( - ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.filterTrigger, - Boolean(hovered) && styles.filterTriggerHovered, - pressed && styles.filterTriggerPressed, - ], - [], - ); - - return ( - - - - {selectedHost === ALL_HOSTS_OPTION_ID ? ( - - ) : ( - - )} - - {selectedHostLabel} - - - - - - ); -} - function SessionsScreenContent() { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -152,10 +82,11 @@ function SessionsScreenContent() { {showHostFilter ? ( - ) : null} @@ -207,32 +138,6 @@ const styles = StyleSheet.create((theme) => ({ }, paddingTop: theme.spacing[4], }, - filterTriggerWrap: { - alignSelf: "flex-start", - }, - filterTrigger: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1.5], - alignSelf: "flex-start", - paddingVertical: theme.spacing[1.5], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface1, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - }, - filterTriggerHovered: { - backgroundColor: theme.colors.surface2, - }, - filterTriggerPressed: { - backgroundColor: theme.colors.surface3, - }, - filterTriggerText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - }, emptyContainer: { flex: 1, justifyContent: "center", diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 71f2bac33..9ed7f8e2e 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -2803,12 +2803,14 @@ describe("create_schedule MCP tool", () => { it("requires provider for schedules", async () => { const { agentManager, agentStorage } = createTestDeps(); - const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input)); + const createOrReplace = vi.fn(async (input: CreateScheduleInput) => + createStoredSchedule(input), + ); const server = await createAgentMcpServer({ agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { create } as unknown as ScheduleService, + scheduleService: { createOrReplace } as unknown as ScheduleService, logger, }); const tool = registeredTool(server, "create_schedule"); @@ -2820,17 +2822,19 @@ describe("create_schedule MCP tool", () => { name: "Default schedule", }), ).rejects.toThrow("provider is required when target is new-agent"); - expect(create).not.toHaveBeenCalled(); + expect(createOrReplace).not.toHaveBeenCalled(); }); it("keeps create_schedule provider overrides compatible with provider and provider/model forms", async () => { const { agentManager, agentStorage } = createTestDeps(); - const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input)); + const createOrReplace = vi.fn(async (input: CreateScheduleInput) => + createStoredSchedule(input), + ); const server = await createAgentMcpServer({ agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { create } as unknown as ScheduleService, + scheduleService: { createOrReplace } as unknown as ScheduleService, logger, }); const tool = registeredTool(server, "create_schedule"); @@ -2846,7 +2850,7 @@ describe("create_schedule MCP tool", () => { provider: "codex/gpt-5.4", }); - expect(create).toHaveBeenNthCalledWith( + expect(createOrReplace).toHaveBeenNthCalledWith( 1, expect.objectContaining({ target: { @@ -2858,7 +2862,7 @@ describe("create_schedule MCP tool", () => { }, }), ); - expect(create).toHaveBeenNthCalledWith( + expect(createOrReplace).toHaveBeenNthCalledWith( 2, expect.objectContaining({ target: { @@ -2888,12 +2892,14 @@ describe("create_schedule MCP tool", () => { featureValues: { auto_accept: true }, }, } as ManagedAgent); - const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input)); + const createOrReplace = vi.fn(async (input: CreateScheduleInput) => + createStoredSchedule(input), + ); const server = await createAgentMcpServer({ agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { create } as unknown as ScheduleService, + scheduleService: { createOrReplace } as unknown as ScheduleService, callerAgentId: "parent-agent", logger, }); @@ -2914,14 +2920,14 @@ describe("create_schedule MCP tool", () => { it("passes timezone through cron create_schedule input", async () => { const { agentManager, agentStorage } = createTestDeps(); - const create = vi.fn(async (scheduleInput: CreateScheduleInput) => + const createOrReplace = vi.fn(async (scheduleInput: CreateScheduleInput) => createStoredSchedule(scheduleInput), ); const server = await createAgentMcpServer({ agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { create } as unknown as ScheduleService, + scheduleService: { createOrReplace } as unknown as ScheduleService, logger, }); const tool = registeredTool(server, "create_schedule"); @@ -2933,7 +2939,7 @@ describe("create_schedule MCP tool", () => { provider: "codex", }); - expect(create).toHaveBeenCalledWith( + expect(createOrReplace).toHaveBeenCalledWith( expect.objectContaining({ cadence: { type: "cron", @@ -2946,12 +2952,12 @@ describe("create_schedule MCP tool", () => { it("rejects removed create_schedule every input", async () => { const { agentManager, agentStorage } = createTestDeps(); - const create = vi.fn(); + const createOrReplace = vi.fn(); const server = await createAgentMcpServer({ agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { create } as unknown as ScheduleService, + scheduleService: { createOrReplace } as unknown as ScheduleService, logger, }); const tool = registeredTool(server, "create_schedule"); @@ -2963,17 +2969,17 @@ describe("create_schedule MCP tool", () => { }); expect(parsed.success).toBe(false); - expect(create).not.toHaveBeenCalled(); + expect(createOrReplace).not.toHaveBeenCalled(); }); it("rejects create_schedule without cron", async () => { const { agentManager, agentStorage } = createTestDeps(); - const create = vi.fn(); + const createOrReplace = vi.fn(); const server = await createAgentMcpServer({ agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { create } as unknown as ScheduleService, + scheduleService: { createOrReplace } as unknown as ScheduleService, logger, }); const tool = registeredTool(server, "create_schedule"); @@ -2985,17 +2991,17 @@ describe("create_schedule MCP tool", () => { }), ).rejects.toThrow(/cron/); - expect(create).not.toHaveBeenCalled(); + expect(createOrReplace).not.toHaveBeenCalled(); }); it.each(["", " "])("rejects create_schedule blank timezone %#", async (timezone) => { const { agentManager, agentStorage } = createTestDeps(); - const create = vi.fn(); + const createOrReplace = vi.fn(); const server = await createAgentMcpServer({ agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { create } as unknown as ScheduleService, + scheduleService: { createOrReplace } as unknown as ScheduleService, logger, }); const tool = registeredTool(server, "create_schedule"); @@ -3009,7 +3015,7 @@ describe("create_schedule MCP tool", () => { }), ).rejects.toThrow(); - expect(create).not.toHaveBeenCalled(); + expect(createOrReplace).not.toHaveBeenCalled(); }); }); @@ -3027,12 +3033,14 @@ describe("create_heartbeat MCP tool", () => { availableModes: [], config: { title: "Parent agent" }, } as ManagedAgent); - const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input)); + const createOrReplace = vi.fn(async (input: CreateScheduleInput) => + createStoredSchedule(input), + ); const server = await createAgentMcpServer({ agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { create } as unknown as ScheduleService, + scheduleService: { createOrReplace } as unknown as ScheduleService, callerAgentId: "parent-agent", logger, }); @@ -3045,7 +3053,7 @@ describe("create_heartbeat MCP tool", () => { name: "status heartbeat", }); - expect(create).toHaveBeenCalledWith( + expect(createOrReplace).toHaveBeenCalledWith( expect.objectContaining({ prompt: "check status", cadence: { @@ -3061,12 +3069,12 @@ describe("create_heartbeat MCP tool", () => { it("requires an agent-scoped session", async () => { const { agentManager, agentStorage } = createTestDeps(); - const create = vi.fn(); + const createOrReplace = vi.fn(); const server = await createAgentMcpServer({ agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { create } as unknown as ScheduleService, + scheduleService: { createOrReplace } as unknown as ScheduleService, logger, }); const tool = registeredTool(server, "create_heartbeat"); @@ -3078,7 +3086,7 @@ describe("create_heartbeat MCP tool", () => { }), ).rejects.toThrow("create_heartbeat requires an agent-scoped session"); - expect(create).not.toHaveBeenCalled(); + expect(createOrReplace).not.toHaveBeenCalled(); }); }); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 75c2eb23f..9d6d75150 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -2009,7 +2009,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } const expiresAt = buildScheduleExpiry(expiresIn); - const schedule = await scheduleService.create({ + const schedule = await scheduleService.createOrReplace({ prompt: prompt.trim(), cadence: buildCronScheduleCadence({ cron, @@ -2058,7 +2058,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase resolveCallerAgent(); const expiresAt = buildScheduleExpiry(expiresIn); - const schedule = await scheduleService.create({ + const schedule = await scheduleService.createOrReplace({ prompt: prompt.trim(), cadence: buildCronScheduleCadence({ cron, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 0534af4db..1ce92dcdf 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -818,9 +818,9 @@ export async function createPaseoDaemon( await scheduleService.start(); agentManager.setAgentArchivedCallback(async (agentId) => { try { - await scheduleService.deleteForAgent(agentId); + await scheduleService.completeForAgent(agentId); } catch (error) { - logger.warn({ err: error, agentId }, "Failed to delete schedules for archived agent"); + logger.warn({ err: error, agentId }, "Failed to complete schedules for archived agent"); } }); logger.info({ elapsed: elapsed() }, "Schedule service initialized"); diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 1bdb220e6..ec2db926b 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -22,7 +22,7 @@ import type { import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; -import { ScheduleService } from "./service.js"; +import { ScheduleService, ScheduleTargetGoneError } from "./service.js"; import type { ScheduleExecutionResult, StoredSchedule } from "@getpaseo/protocol/schedule/types"; interface ScheduleServiceInternals { @@ -45,6 +45,36 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick { let tempDir: string; let agentStorage: AgentStorage; @@ -1266,7 +1296,7 @@ describe("ScheduleService", () => { await expect(service.runOnce(created.id)).rejects.toThrow("already completed"); }); - test("deleteForAgent removes only schedules targeting that agent", async () => { + test("completeForAgent completes only schedules targeting that agent", async () => { const service = new ScheduleService({ paseoHome: tempDir, logger: createTestLogger(), @@ -1299,12 +1329,577 @@ describe("ScheduleService", () => { }, }); - const deleted = await service.deleteForAgent(targetAgentId); - expect(deleted).toBe(1); + now = new Date("2026-01-01T00:05:00.000Z"); + const completed = await service.completeForAgent(targetAgentId); + expect(completed).toBe(1); const remaining = await service.list(); - const remainingIds = remaining.map((schedule) => schedule.id).sort(); - expect(remainingIds).toEqual([otherTargeted.id, newAgentSchedule.id].sort()); - expect(remainingIds).not.toContain(targeted.id); + expect(remaining.map((schedule) => schedule.id).sort()).toEqual( + [targeted.id, otherTargeted.id, newAgentSchedule.id].sort(), + ); + + const doomed = await service.inspect(targeted.id); + expect(doomed.status).toBe("completed"); + expect(doomed.nextRunAt).toBeNull(); + expect(doomed.updatedAt).toBe("2026-01-01T00:05:00.000Z"); + + expect((await service.inspect(otherTargeted.id)).status).toBe("active"); + expect((await service.inspect(newAgentSchedule.id)).status).toBe("active"); + }); + + test("startup sweep completes agent-target schedules whose agent is gone", async () => { + const missingAgentId = "44444444-4444-4444-8444-444444444444"; + const archivedAgentId = "55555555-5555-4555-8555-555555555555"; + const liveAgentId = "66666666-6666-4666-8666-666666666666"; + + await agentStorage.upsert( + buildAgentRecord({ + id: archivedAgentId, + cwd: tempDir, + iso: now.toISOString(), + archivedAt: "2026-01-01T00:00:30.000Z", + }), + ); + await agentStorage.upsert( + buildAgentRecord({ id: liveAgentId, cwd: tempDir, iso: now.toISOString() }), + ); + + const service1 = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const missing = await service1.create({ + prompt: "ping missing", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: missingAgentId }, + }); + const archived = await service1.create({ + prompt: "ping archived", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: archivedAgentId }, + }); + const live = await service1.create({ + prompt: "ping live", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: liveAgentId }, + }); + const newAgent = await service1.create({ + prompt: "spawn fresh", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + const pausedMissing = await service1.create({ + prompt: "paused ping missing", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1" }, + }); + await service1.pause(pausedMissing.id); + const pausedLive = await service1.create({ + prompt: "paused ping live", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: liveAgentId }, + }); + await service1.pause(pausedLive.id); + + now = new Date("2026-01-01T00:10:00.000Z"); + const service2 = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + await service2.start(); + await service2.stop(); + + expect((await service2.inspect(missing.id)).status).toBe("completed"); + expect((await service2.inspect(missing.id)).nextRunAt).toBeNull(); + expect((await service2.inspect(archived.id)).status).toBe("completed"); + expect((await service2.inspect(live.id)).status).toBe("active"); + expect((await service2.inspect(newAgent.id)).status).toBe("active"); + // Paused schedules are swept too when their agent is gone, but survive when it lives. + expect((await service2.inspect(pausedMissing.id)).status).toBe("completed"); + expect((await service2.inspect(pausedLive.id)).status).toBe("paused"); + }); + + test("completes the schedule when a scheduled run reports the target is gone", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => { + throw new ScheduleTargetGoneError("Agent 77777777-7777-4777-8777-777777777777 is archived"); + }, + }); + + const created = await service.create({ + prompt: "ping gone target", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: "77777777-7777-4777-8777-777777777777" }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.status).toBe("completed"); + expect(inspected.nextRunAt).toBeNull(); + expect(inspected.runs).toHaveLength(1); + expect(inspected.runs[0]?.status).toBe("failed"); + expect(inspected.runs[0]?.error).toBe("Agent 77777777-7777-4777-8777-777777777777 is archived"); + }); + + test("does not resurrect nextRunAt when the schedule completes during an in-flight run", async () => { + const agentId = "ffffffff-ffff-4fff-8fff-ffffffffffff"; + let service!: ScheduleService; + service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => { + // Simulate the agent being archived mid-run: the archive callback + // completes the schedule before this run finishes. + await service.completeForAgent(agentId); + return { agentId: null, output: "ok" }; + }, + }); + + const created = await service.create({ + prompt: "ping", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.status).toBe("completed"); + expect(inspected.nextRunAt).toBeNull(); + expect(inspected.runs[0]?.status).toBe("succeeded"); + }); + + test("keeps the schedule active when a run fails for a transient reason", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => { + throw new Error("network blip"); + }, + }); + + const created = await service.create({ + prompt: "ping flaky target", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: "88888888-8888-4888-8888-888888888888" }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.status).toBe("active"); + expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); + expect(inspected.runs[0]?.status).toBe("failed"); + expect(inspected.runs[0]?.error).toBe("network blip"); + }); + + test("completes the schedule when a scheduled run targets an archived agent", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + }); + + const archivedAgentId = "99999999-9999-4999-8999-999999999999"; + await agentStorage.upsert( + buildAgentRecord({ + id: archivedAgentId, + cwd: tempDir, + iso: now.toISOString(), + archivedAt: "2026-01-01T00:00:30.000Z", + }), + ); + + const created = await service.create({ + prompt: "ping archived target", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: archivedAgentId }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.status).toBe("completed"); + expect(inspected.nextRunAt).toBeNull(); + expect(inspected.runs[0]?.status).toBe("failed"); + expect(inspected.runs[0]?.error).toContain("is archived"); + }); + + test("completes the schedule when a scheduled run targets a missing agent", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + }); + + const created = await service.create({ + prompt: "ping missing target", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.status).toBe("completed"); + expect(inspected.nextRunAt).toBeNull(); + expect(inspected.runs[0]?.status).toBe("failed"); + }); + + test("completes the schedule when a new-agent run's cwd no longer exists", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + }); + + const created = await service.create({ + prompt: "spawn in a deleted dir", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: join(tempDir, "deleted-worktree"), + approvalPolicy: "never", + }, + }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.status).toBe("completed"); + expect(inspected.nextRunAt).toBeNull(); + expect(inspected.runs[0]?.status).toBe("failed"); + expect(inspected.runs[0]?.error).toContain("no longer exists"); + }); + + test("keeps the schedule active when a real run fails for a non-gone reason", async () => { + // No providers registered: the agent exists and is live, but loading it fails + // with a plain error (not ScheduleTargetGoneError), so the schedule must retry. + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + }); + + const agentId = "12121212-1212-4121-8121-121212121212"; + await agentStorage.upsert( + buildAgentRecord({ id: agentId, cwd: tempDir, iso: now.toISOString() }), + ); + + const created = await service.create({ + prompt: "ping live but unavailable", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.status).toBe("active"); + expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); + expect(inspected.runs[0]?.status).toBe("failed"); + expect(inspected.runs[0]?.error).toContain("unavailable provider"); + }); + + test("runOnce completes the schedule when the target is gone", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + }); + + const created = await service.create({ + prompt: "manual ping gone target", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: "13131313-1313-4131-8131-131313131313" }, + }); + + const after = await service.runOnce(created.id); + expect(after.status).toBe("completed"); + expect(after.nextRunAt).toBeNull(); + expect(after.runs).toHaveLength(1); + expect(after.runs[0]?.status).toBe("failed"); + }); + + test("createOrReplace updates the matching schedule in place instead of duplicating", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const agentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const first = await service.createOrReplace({ + name: "babysit-pr PR 1112", + prompt: "watch the build", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + expect((await service.inspect(first.id)).runs).toHaveLength(1); + await service.pause(first.id); + + now = new Date("2026-01-01T00:02:00.000Z"); + const second = await service.createOrReplace({ + name: "babysit-pr PR 1112", + prompt: "watch the build v2", + cadence: { type: "cron", expression: "30 9 * * *" }, + target: { type: "agent", agentId }, + }); + + expect(second.id).toBe(first.id); + expect(second.status).toBe("active"); + expect(second.prompt).toBe("watch the build v2"); + expect(second.cadence).toEqual({ type: "cron", expression: "30 9 * * *" }); + expect(second.nextRunAt).toBe("2026-01-01T09:30:00.000Z"); + expect(second.createdAt).toBe(first.createdAt); + expect(second.runs).toHaveLength(1); + expect(await service.list()).toHaveLength(1); + }); + + test("createOrReplace creates a sibling when name, target, or completion differ", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const agentA = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + const agentB = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + + await service.createOrReplace({ + name: "dup", + prompt: "p", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: agentA }, + }); + await service.createOrReplace({ + name: "other", + prompt: "p", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: agentA }, + }); + await service.createOrReplace({ + name: "dup", + prompt: "p", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: agentB }, + }); + + const done = await service.createOrReplace({ + name: "done", + prompt: "p", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: agentA }, + maxRuns: 1, + }); + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + expect((await service.inspect(done.id)).status).toBe("completed"); + + const redone = await service.createOrReplace({ + name: "done", + prompt: "p", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: agentA }, + }); + expect(redone.id).not.toBe(done.id); + + expect(await service.list()).toHaveLength(5); + }); + + test("createOrReplace never dedups anonymous schedules", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const agentId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + await service.createOrReplace({ + prompt: "p", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId }, + }); + await service.createOrReplace({ + prompt: "p", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId }, + }); + + expect(await service.list()).toHaveLength(2); + }); + + test("createOrReplace matches new-agent targets by config", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const first = await service.createOrReplace({ + name: "nightly", + prompt: "audit", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + const second = await service.createOrReplace({ + name: "nightly", + prompt: "audit v2", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + expect(second.id).toBe(first.id); + expect(await service.list()).toHaveLength(1); + + const third = await service.createOrReplace({ + name: "nightly", + prompt: "audit elsewhere", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: join(tempDir, "sub") } }, + }); + expect(third.id).not.toBe(first.id); + expect(await service.list()).toHaveLength(2); + }); + + test("createOrReplace dedups new-agent targets regardless of config key order", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + // The stored config is round-tripped through the Zod schema (schema key + // order); this incoming literal deliberately uses a different key order. + const first = await service.createOrReplace({ + name: "nightly", + prompt: "audit", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: tempDir, + networkAccess: true, + title: "nightly job", + approvalPolicy: "never", + }, + }, + }); + const second = await service.createOrReplace({ + name: "nightly", + prompt: "audit v2", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: tempDir, + networkAccess: true, + title: "nightly job", + approvalPolicy: "never", + }, + }, + }); + + expect(second.id).toBe(first.id); + expect(await service.list()).toHaveLength(1); + }); + + test("completeForAgent skips schedules that are already completed", async () => { + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const agentId = "33333333-3333-4333-8333-333333333333"; + await service.create({ + prompt: "already done", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId }, + maxRuns: 1, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + expect(await service.completeForAgent(agentId)).toBe(1); + expect(await service.completeForAgent(agentId)).toBe(0); }); }); diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 5e89a0d48..6dd196e1b 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { stat } from "node:fs/promises"; import { join } from "node:path"; import type { Logger } from "pino"; import { AgentManager } from "../agent/agent-manager.js"; @@ -27,6 +28,16 @@ import type { const SCHEDULE_TICK_INTERVAL_MS = 1000; +// A run failed because its target no longer exists: the agent was deleted or +// archived, or a new-agent cwd was removed. These are permanent, so the schedule +// is completed instead of retried until it burns down to its expiry. +export class ScheduleTargetGoneError extends Error { + constructor(message: string) { + super(message); + this.name = "ScheduleTargetGoneError"; + } +} + function trimOptionalName(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -112,6 +123,35 @@ function shouldCompleteSchedule(schedule: StoredSchedule, now: Date): boolean { return countCompletedRuns(schedule) >= schedule.maxRuns; } +// Sort object keys recursively so two structurally-equal configs serialize +// identically regardless of key order. Stored configs come back from disk in Zod +// schema order while incoming configs keep their construction order, so a plain +// JSON.stringify comparison would wrongly treat identical targets as different. +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (value && typeof value === "object") { + const source = value as Record; + return Object.fromEntries( + Object.keys(source) + .sort() + .map((key) => [key, canonicalize(source[key])]), + ); + } + return value; +} + +function scheduleTargetsEqual(a: ScheduleTarget, b: ScheduleTarget): boolean { + if (a.type === "agent" && b.type === "agent") { + return a.agentId === b.agentId; + } + if (a.type === "new-agent" && b.type === "new-agent") { + return JSON.stringify(canonicalize(a.config)) === JSON.stringify(canonicalize(b.config)); + } + return false; +} + function completeSchedule(schedule: StoredSchedule, now: Date): StoredSchedule { return { ...schedule, @@ -177,6 +217,7 @@ export class ScheduleService { async start(): Promise { await this.recoverInterruptedRuns(); + await this.sweepOrphanedSchedules(); if (this.tickTimer) { return; } @@ -220,6 +261,44 @@ export class ScheduleService { return schedule; } + // Idempotent create for the MCP write path: repeating a create with the same + // name and target (e.g. babysit-pr re-registering its heartbeat) refreshes the + // existing non-completed schedule in place instead of minting a duplicate. + async createOrReplace(input: CreateScheduleInput): Promise { + const name = trimOptionalName(input.name); + if (name !== null) { + const existing = (await this.store.list()).find( + (schedule) => + schedule.status !== "completed" && + trimOptionalName(schedule.name) === name && + scheduleTargetsEqual(schedule.target, input.target), + ); + if (existing) { + const now = this.now(); + const prompt = normalizePrompt(input.prompt); + validateScheduleCadence(input.cadence); + const runOnCreate = input.runOnCreate ?? input.cadence.type === "every"; + const nextRunAt = runOnCreate ? now : computeNextRunAt(input.cadence, now); + const replaced: StoredSchedule = { + ...existing, + name, + prompt, + cadence: input.cadence, + target: input.target, + status: "active", + pausedAt: null, + nextRunAt: nextRunAt.toISOString(), + expiresAt: input.expiresAt ?? null, + maxRuns: normalizeMaxRuns(input.maxRuns), + updatedAt: now.toISOString(), + }; + await this.store.put(replaced); + return replaced; + } + } + return this.create(input); + } + async list(): Promise { return this.store.list(); } @@ -321,26 +400,30 @@ export class ScheduleService { await this.store.delete(id); } - async deleteForAgent(agentId: string): Promise { + async completeForAgent(agentId: string): Promise { + const now = this.now(); const schedules = await this.store.list(); const matches = schedules.filter( - (schedule) => schedule.target.type === "agent" && schedule.target.agentId === agentId, + (schedule) => + schedule.target.type === "agent" && + schedule.target.agentId === agentId && + schedule.status !== "completed", ); const results = await Promise.allSettled( - matches.map((schedule) => this.store.delete(schedule.id)), + matches.map((schedule) => this.store.put(completeSchedule(schedule, now))), ); - let deleted = 0; + let completed = 0; for (const [index, result] of results.entries()) { if (result.status === "fulfilled") { - deleted += 1; + completed += 1; } else { this.logger.warn( { err: result.reason, scheduleId: matches[index].id, agentId }, - "Failed to delete schedule for archived agent; continuing", + "Failed to complete schedule for archived agent; continuing", ); } } - return deleted; + return completed; } async runOnce(id: string): Promise { @@ -420,6 +503,26 @@ export class ScheduleService { ); } + // Orphaned agent-target schedules (agent deleted while the daemon was down, or + // archived before completeForAgent existed) can never fire successfully. Complete + // them on startup so they stop ticking and surface as ended in the UI. + private async sweepOrphanedSchedules(): Promise { + const now = this.now(); + const schedules = await this.store.list(); + await Promise.all( + schedules.map(async (schedule) => { + if (schedule.target.type !== "agent" || schedule.status === "completed") { + return; + } + const record = await this.agentStorage.get(schedule.target.agentId); + if (record && !record.archivedAt) { + return; + } + await this.store.put(completeSchedule(schedule, now)); + }), + ); + } + private async runSchedule( schedule: StoredSchedule, now: Date, @@ -454,6 +557,7 @@ export class ScheduleService { agentId: result.agentId, output: result.output, error: null, + targetGone: false, manual, }); } catch (error) { @@ -464,6 +568,7 @@ export class ScheduleService { agentId: null, output: null, error: error instanceof Error ? error.message : String(error), + targetGone: error instanceof ScheduleTargetGoneError, manual, }); } finally { @@ -478,6 +583,7 @@ export class ScheduleService { agentId: string | null; output: string | null; error: string | null; + targetGone: boolean; manual: boolean; }): Promise { const schedule = await this.inspect(params.scheduleId); @@ -501,7 +607,14 @@ export class ScheduleService { updatedAt: now.toISOString(), }; - if (params.manual) { + if (params.targetGone) { + // The target is permanently gone; retrying only burns the schedule down to + // its expiry, so complete it now regardless of manual/scheduled origin. + updated = completeSchedule(updated, now); + } else if (updated.status === "completed") { + // Completed concurrently (e.g. the target agent was archived mid-run); + // record the run outcome but leave the schedule terminal — don't advance. + } else if (params.manual) { // Manual one-shot runs do not advance the cadence or recompute completion. } else if (shouldCompleteSchedule(updated, now)) { updated = completeSchedule(updated, now); @@ -532,8 +645,11 @@ export class ScheduleService { if (schedule.target.type === "agent") { const wrappedPrompt = formatSystemNotificationPrompt(buildScheduleFireBody(schedule, runId)); const record = await this.agentStorage.get(schedule.target.agentId); - if (record?.archivedAt) { - throw new Error(`Agent ${schedule.target.agentId} is archived`); + if (!record) { + throw new ScheduleTargetGoneError(`Agent ${schedule.target.agentId} no longer exists`); + } + if (record.archivedAt) { + throw new ScheduleTargetGoneError(`Agent ${schedule.target.agentId} is archived`); } const agent = await ensureAgentLoaded(schedule.target.agentId, { @@ -557,6 +673,14 @@ export class ScheduleService { } const targetConfig = schedule.target.config; + try { + await stat(targetConfig.cwd); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new ScheduleTargetGoneError(`Working directory ${targetConfig.cwd} no longer exists`); + } + throw error; + } const resolvedUnattendedConfig = targetConfig.modeId ? { modeId: targetConfig.modeId, featureValues: targetConfig.featureValues } : await this.resolveProviderCreateConfig({