Polish schedules list and editing (#1860)

* feat(schedules): polish schedules list and editing

Adds host filtering, status grouping, and target resolution to the
schedules screen. Extracts a shared HostFilter from sessions. Improves
schedule editing with project targets and model hydration. Handles
missing targets server-side instead of retrying forever.

* test(mcp): update schedule mocks for createOrReplace

* fix(schedules): only show host name when multiple hosts exist
This commit is contained in:
Mohamed Boudra
2026-07-02 12:05:35 +02:00
committed by GitHub
parent 2263469342
commit 6513b56571
21 changed files with 1981 additions and 551 deletions

View File

@@ -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 });

View File

@@ -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();

View File

@@ -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 / <host>" 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<View>(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 (
<HostPicker
hosts={hosts}
value={selectedHost}
onSelect={onSelectHost}
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
includeAllHost
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID={triggerTestID}
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<ThemedServer size={14} uniProps={mutedColorMapping} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</View>
</HostPicker>
);
}
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,
},
}));

View File

@@ -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"
/>
</View>
@@ -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],
},
}));

View File

@@ -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<string, ScheduleProjectTarget>();
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<boolean> => {
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<boolean> => {
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({
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<ProjectField
options={projectOptions.options}
targetByOptionId={projectOptions.targetByOptionId}
value={selectedProjectOptionId}
selectedTarget={selectedProjectTarget}
onSelect={handleSelectProject}
/>
</View>
{isAgentTarget ? (
<View style={styles.field}>
<Text style={styles.label}>Target</Text>
<View style={styles.readonlyField} testID="schedule-agent-target">
<Text style={styles.selectTriggerText} numberOfLines={1}>
{agentTargetLabel}
</Text>
</View>
<Text style={styles.hint}>Runs against this existing agent.</Text>
</View>
) : (
<>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<ProjectField
options={projectOptions.options}
targetByOptionId={projectOptions.targetByOptionId}
value={selectedProjectOptionId}
selectedTarget={selectedProjectTarget}
fallbackCwd={workingDir}
onSelect={handleSelectProject}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Model</Text>
<CombinedModelSelector
providers={modelSelectorProviders}
selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel}
onSelect={setProviderAndModelFromUser}
isLoading={isAllModelsLoading}
renderTrigger={renderModelTrigger}
triggerFill
serverId={mutationServerId}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Model</Text>
<CombinedModelSelector
providers={modelSelectorProviders}
selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel}
onSelect={setProviderAndModelFromUser}
isLoading={isAllModelsLoading}
renderTrigger={renderModelTrigger}
triggerFill
serverId={mutationServerId}
/>
</View>
{modeOptions.length > 0 ? (
<ModeField options={modeOptions} selectedMode={selectedMode} onSelect={setModeFromUser} />
) : null}
{modeOptions.length > 0 ? (
<ModeField
options={modeOptions}
selectedMode={selectedMode}
onSelect={setModeFromUser}
/>
) : null}
</>
)}
<View style={styles.field}>
<Text style={styles.label}>Cadence</Text>
@@ -504,10 +581,6 @@ export function ScheduleFormSheet({
<Text style={styles.hint}>Leave blank to run indefinitely</Text>
</View>
{editConfig === null && isEdit ? (
<Text style={styles.hint}>This schedule does not target a new agent.</Text>
) : null}
{submitError ? <Text style={styles.error}>{submitError}</Text> : null}
</AdaptiveModalSheet>
);
@@ -594,12 +667,15 @@ function ProjectField({
targetByOptionId,
value,
selectedTarget,
fallbackCwd,
onSelect,
}: {
options: ComboboxOption[];
targetByOptionId: Map<string, ScheduleProjectTarget>;
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<View>(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"
>
<Text
style={selectedTarget ? styles.selectTriggerText : styles.selectTriggerPlaceholder}
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
numberOfLines={1}
>
{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",

View File

@@ -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({
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.target} numberOfLines={1}>
{targetLabel}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{meta}
</Text>
@@ -175,12 +199,10 @@ export function ScheduleRow({
</View>
<View style={styles.trailing}>
<StatusBadge
label={statusLabel(schedule.status)}
variant={statusVariant(schedule.status)}
/>
<StatusBadge label={badge.label} variant={badge.variant} />
<ScheduleKebabMenu
schedule={schedule}
canRun={canRun}
pending={pending}
onEdit={onEdit}
onPause={onPause}
@@ -211,13 +233,19 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElemen
function ScheduleKebabMenu({
schedule,
canRun,
pending,
onEdit,
onPause,
onResume,
onRunNow,
onDelete,
}: Omit<ScheduleRowProps, "isFirst">): ReactElement {
}: Pick<
ScheduleRowProps,
"schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete"
> & {
canRun: boolean;
}): ReactElement {
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -240,6 +268,7 @@ function ScheduleKebabMenu({
{schedule.status === "paused" ? (
<DropdownMenuItem
leading={resumeLeading}
disabled={!canRun}
status={pending?.resume ? "pending" : "idle"}
pendingLabel="Resuming..."
onSelect={onResume}
@@ -250,7 +279,7 @@ function ScheduleKebabMenu({
) : (
<DropdownMenuItem
leading={pauseLeading}
disabled={schedule.status === "completed"}
disabled={schedule.status === "completed" || !canRun}
status={pending?.pause ? "pending" : "idle"}
pendingLabel="Pausing..."
onSelect={onPause}
@@ -261,6 +290,7 @@ function ScheduleKebabMenu({
)}
<DropdownMenuItem
leading={runLeading}
disabled={!canRun}
status={pending?.runNow ? "pending" : "idle"}
pendingLabel="Starting..."
onSelect={onRunNow}
@@ -324,6 +354,11 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
target: {
marginTop: theme.spacing[1],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
trailing: {
flexDirection: "row",
alignItems: "center",

View File

@@ -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 (
<View style={styles.listContent} testID="schedules-table">
<View style={settingsStyles.card}>
{schedules.map((schedule, index) => (
{rows.map((row, index) => (
<SchedulesTableRow
key={schedule.id}
schedule={schedule}
key={`${row.schedule.serverId}:${row.schedule.id}`}
row={row}
isFirst={index === 0}
mutations={mutations}
onEditSchedule={onEditSchedule}
/>
))}
@@ -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<typeof useScheduleMutations>;
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<ScheduleRowPending>(NO_PENDING);
const runAction = useCallback(
@@ -127,6 +129,11 @@ function SchedulesTableRow({
return (
<ScheduleRow
schedule={schedule}
targetLabel={row.targetLabel}
provider={row.provider}
state={row.state}
serverName={row.serverName}
singleHost={row.singleHost}
isFirst={isFirst}
pending={pending}
onEdit={handleEdit}

View File

@@ -13,7 +13,10 @@ import type {
} from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
import type { FetchAggregatedSchedulesResult } from "@/schedules/aggregated-schedules";
import type {
AggregatedSchedule,
FetchAggregatedSchedulesResult,
} from "@/schedules/aggregated-schedules";
import { useSessionStore } from "@/stores/session-store";
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
@@ -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<FetchAggregatedSchedulesResult>(
{ 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)),
);
}

View File

@@ -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<ScheduleHostInput[]>(
() =>
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: () => {

View File

@@ -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<FetchAggregatedSchedulesResult> {
const sections = await Promise.all(
input.hosts.map(async (host): Promise<ScheduleHostSection> => {
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 };
}

View File

@@ -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>): 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" });
});
});

View File

@@ -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<string, ScheduleTargetAgent>;
/** Known project roots keyed by `${serverId}:${cwd}`. */
projectNameByCwd: ReadonlyMap<string, string>;
/**
* 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),
};
}

View File

@@ -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>): ProjectSummary {
return {
projectKey: "proj",
projectName: "Project",
hosts: [],
totalWorkspaceCount: 0,
hostCount: 0,
onlineHostCount: 0,
...overrides,
};
}
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
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");
});
});

View File

@@ -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<string, string> {
const byCwd = new Map<string, string>();
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, string>;
}): string {
return (
input.projectNameByCwd.get(projectNameKey(input.serverId, input.cwd)) ?? shortenPath(input.cwd)
);
}

View File

@@ -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<string>();
for (const host of hosts) {
if (runtime.getSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) {
ready.add(host.serverId);
}
}
return ready;
}, [hosts, runtime, runtimeVersion]);
const [form, setForm] = useState<FormState>({ mode: "closed" });
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
const [statusFilter, setStatusFilter] = useState<ScheduleBucket>("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<string, ScheduleTargetAgent>();
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<ScheduleRowView[]>(() => {
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 (
<View style={styles.container}>
<MenuHeader title="Schedules" rightContent={headerAction} />
<SchedulesBody
sections={sections}
isLoading={isLoading}
isError={isError}
error={error}
<SchedulesScreenBody
rows={visibleRows}
hostErrors={hostErrors}
hasSchedules={schedules.length > 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<typeof useHosts>;
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 (
<View style={styles.centered}>
<LoadingSpinner size="large" color={styles.spinner.color} />
@@ -99,85 +235,95 @@ function SchedulesBody({
);
}
if (isError) {
if (showLoadError) {
return (
<View style={styles.centered}>
<Text style={styles.message}>{error?.message ?? "Could not load schedules"}</Text>
<Text style={styles.message}>Unable to load schedules</Text>
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
Retry
Try again
</Button>
</View>
);
}
if (sections.length === 0) {
if (!hasSchedules) {
return (
<View style={styles.centered}>
<View style={styles.centered} testID="schedules-empty">
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
<Text style={styles.message}>No schedules yet</Text>
<Button leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
New schedule
<Button variant="ghost" leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
Create a schedule
</Button>
</View>
);
}
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
testID="schedules-sections"
>
{sections.map((section) => (
<ScheduleHostSectionView key={section.serverId} section={section} onEdit={onEdit} />
))}
</ScrollView>
);
}
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 (
<View style={styles.section} testID={`schedules-section-${section.serverId}`}>
<Text style={styles.sectionTitle} testID={`schedules-section-title-${section.serverId}`}>
{section.serverName}
</Text>
{section.error ? <Text style={styles.sectionError}>{section.error}</Text> : null}
{section.schedules.length > 0 ? (
<SchedulesTable
serverId={section.serverId}
schedules={section.schedules}
onEditSchedule={handleEdit}
<View style={styles.body}>
<View style={styles.filterRow}>
{showHostFilter ? (
<HostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={onSelectHost}
triggerTestID="schedules-host-filter-trigger"
/>
) : null}
<SegmentedControl
size="sm"
value={statusFilter}
onValueChange={onStatusFilterChange}
options={STATUS_FILTER_OPTIONS}
testID="schedules-status-filter"
/>
) : null}
{section.schedules.length === 0 && !section.error ? (
<View style={styles.sectionEmpty}>
<Text style={styles.sectionEmptyText}>{emptyMessage}</Text>
</View>
) : null}
</View>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
testID="schedules-list"
>
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
{rows.length > 0 ? (
<SchedulesTable rows={rows} onEditSchedule={onEdit} />
) : (
<View style={styles.filterEmpty}>
<Text style={styles.filterEmptyText}>{emptyFilterText}</Text>
</View>
)}
</ScrollView>
</View>
);
}
function ScheduleHostErrorsBanner({ errors }: { errors: ScheduleHostError[] }): ReactElement {
return (
<View style={styles.errorsBannerWrap}>
<View style={styles.errorsBanner} testID="schedules-host-errors">
{errors.map((error) => (
<Text key={error.serverId} style={styles.errorsBannerText}>
{`${error.serverName}: Could not load schedules`}
</Text>
))}
</View>
</View>
);
}
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,
},

View File

@@ -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 <SessionsScreenContent />;
}
function SessionsHostFilter({
hosts,
selectedHost,
onSelectHost,
}: {
hosts: HostProfile[];
selectedHost: string;
onSelectHost: (serverId: string) => void;
}) {
const { theme } = useUnistyles();
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(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 (
<HostPicker
hosts={hosts}
value={selectedHost}
onSelect={onSelectHost}
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
includeAllHost
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="sessions-host-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<Server size={14} color={theme.colors.foregroundMuted} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</HostPicker>
);
}
function SessionsScreenContent() {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -152,10 +82,11 @@ function SessionsScreenContent() {
<MenuHeader title={t("sessions.title")} />
{showHostFilter ? (
<View style={styles.filterContainer}>
<SessionsHostFilter
<HostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
triggerTestID="sessions-host-filter-trigger"
/>
</View>
) : 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",

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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");

View File

@@ -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<ProviderSnapshotManager, "resolveCreat
},
};
function buildAgentRecord(params: {
id: string;
cwd: string;
iso: string;
archivedAt?: string | null;
}) {
return {
id: params.id,
provider: "claude",
cwd: params.cwd,
createdAt: params.iso,
updatedAt: params.iso,
lastActivityAt: params.iso,
lastUserMessageAt: null,
title: params.id,
labels: {},
lastStatus: "closed" as const,
lastModeId: "default",
config: { modeId: "default" },
runtimeInfo: null,
features: [],
persistence: null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: params.archivedAt ?? null,
};
}
describe("ScheduleService", () => {
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);
});
});

View File

@@ -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<string, unknown>;
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<void> {
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<StoredSchedule> {
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<StoredSchedule[]> {
return this.store.list();
}
@@ -321,26 +400,30 @@ export class ScheduleService {
await this.store.delete(id);
}
async deleteForAgent(agentId: string): Promise<number> {
async completeForAgent(agentId: string): Promise<number> {
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<StoredSchedule> {
@@ -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<void> {
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<void> {
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({