mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 20:32:46 +00:00
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:
@@ -76,7 +76,7 @@ test.describe("Schedules", () => {
|
|||||||
await page.goto(buildSchedulesRoute());
|
await page.goto(buildSchedulesRoute());
|
||||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
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 row.click();
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
buildFakeScheduleHostWorkspace,
|
buildFakeScheduleHostWorkspace,
|
||||||
installFakeScheduleHost,
|
installFakeScheduleHost,
|
||||||
} from "./helpers/schedule-fake-host";
|
} from "./helpers/schedule-fake-host";
|
||||||
import { getServerId } from "./helpers/server-id";
|
|
||||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||||
@@ -82,9 +81,9 @@ test.describe("Schedules project target", () => {
|
|||||||
await page.getByRole("button", { name: "Schedules" }).click();
|
await page.getByRole("button", { name: "Schedules" }).click();
|
||||||
await expect(page).toHaveURL(/\/schedules$/);
|
await expect(page).toHaveURL(/\/schedules$/);
|
||||||
await expect(page).not.toHaveURL(/\/h\//);
|
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-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
||||||
|
|
||||||
@@ -127,10 +126,8 @@ test.describe("Schedules project target", () => {
|
|||||||
label: "Fake host",
|
label: "Fake host",
|
||||||
port: fakePort,
|
port: fakePort,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId(`schedules-section-${fakeHost.serverId}`)).toBeVisible({
|
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
|
||||||
timeout: 30_000,
|
await page.getByTestId("schedules-new").click();
|
||||||
});
|
|
||||||
await page.getByRole("button", { name: "New schedule" }).click();
|
|
||||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
await page.getByRole("button", { name: /select project/i }).click();
|
await page.getByRole("button", { name: /select project/i }).click();
|
||||||
|
|||||||
118
packages/app/src/components/hosts/host-filter.tsx
Normal file
118
packages/app/src/components/hosts/host-filter.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -4,7 +4,6 @@ import type { PressableStateCallbackType } from "react-native";
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
import {
|
import {
|
||||||
describeCron,
|
describeCron,
|
||||||
everyMsToParts,
|
everyMsToParts,
|
||||||
@@ -200,7 +199,6 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
|
|||||||
value={intervalUnit}
|
value={intervalUnit}
|
||||||
onValueChange={handleUnitChange}
|
onValueChange={handleUnitChange}
|
||||||
options={UNIT_OPTIONS}
|
options={UNIT_OPTIONS}
|
||||||
style={styles.unitControl}
|
|
||||||
testID="cadence-interval-unit"
|
testID="cadence-interval-unit"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -283,8 +281,6 @@ function CronPresetChip({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo";
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
@@ -308,15 +304,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
// Both cadence segmented controls hug their options and stand at the form's
|
// The mode toggle hugs its options at the left rather than stretching to a
|
||||||
// field height, so the interval row reads as input + toggle rather than a
|
// full-width track; the interval row then reads as input + toggle.
|
||||||
// full-width track with the controls floating inside it.
|
|
||||||
modeControl: {
|
modeControl: {
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
height: 44,
|
|
||||||
},
|
|
||||||
unitControl: {
|
|
||||||
height: 44,
|
|
||||||
},
|
},
|
||||||
presetRow: {
|
presetRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -337,9 +328,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
chipHover: {
|
chipHover: {
|
||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface3,
|
||||||
},
|
},
|
||||||
|
// Selected preset reads as a chosen surface, not a second accent fill
|
||||||
|
// competing with the sheet's primary CTA.
|
||||||
chipSelected: {
|
chipSelected: {
|
||||||
backgroundColor: theme.colors.accent,
|
backgroundColor: theme.colors.surface3,
|
||||||
borderColor: theme.colors.accent,
|
borderColor: theme.colors.borderAccent,
|
||||||
},
|
},
|
||||||
chipLabel: {
|
chipLabel: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -347,7 +340,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
chipLabelSelected: {
|
chipLabelSelected: {
|
||||||
color: theme.colors.accentForeground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
cronInput: {
|
cronInput: {
|
||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
@@ -358,7 +351,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontFamily: MONOSPACE_FONT,
|
fontFamily: theme.fontFamily.mono,
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -370,6 +363,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.destructive,
|
color: theme.colors.palette.red[300],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ import { getProviderIcon } from "@/components/provider-icons";
|
|||||||
import { CadenceEditor } from "@/components/schedules/cadence-editor";
|
import { CadenceEditor } from "@/components/schedules/cadence-editor";
|
||||||
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
||||||
import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state";
|
import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state";
|
||||||
|
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||||
import { useProjects } from "@/hooks/use-projects";
|
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 { validateCron } from "@/utils/schedule-format";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
@@ -32,7 +38,6 @@ import type { ProjectSummary } from "@/utils/projects";
|
|||||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||||
|
|
||||||
const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 };
|
const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 };
|
||||||
const PROJECT_OPTION_PREFIX = "project:";
|
|
||||||
|
|
||||||
export interface ScheduleFormSheetProps {
|
export interface ScheduleFormSheetProps {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
@@ -42,15 +47,6 @@ export interface ScheduleFormSheetProps {
|
|||||||
schedule?: ScheduleSummary;
|
schedule?: ScheduleSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScheduleProjectTarget {
|
|
||||||
optionId: string;
|
|
||||||
serverId: string;
|
|
||||||
serverName: string;
|
|
||||||
projectKey: string;
|
|
||||||
projectName: string;
|
|
||||||
cwd: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScheduleProjectOptions {
|
interface ScheduleProjectOptions {
|
||||||
targets: ScheduleProjectTarget[];
|
targets: ScheduleProjectTarget[];
|
||||||
options: ComboboxOption[];
|
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 {
|
function buildProjectOptionTestId(optionId: string): string {
|
||||||
const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, "");
|
const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, "");
|
||||||
return `schedule-project-option-${targetKey}`;
|
return `schedule-project-option-${targetKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildScheduleProjectOptions(projects: readonly ProjectSummary[]): ScheduleProjectOptions {
|
function buildScheduleProjectOptions(projects: readonly ProjectSummary[]): ScheduleProjectOptions {
|
||||||
const targets: ScheduleProjectTarget[] = [];
|
const targets = buildScheduleProjectTargets(projects);
|
||||||
const targetByOptionId = new Map<string, ScheduleProjectTarget>();
|
const targetByOptionId = new Map(targets.map((target) => [target.optionId, target]));
|
||||||
const options: ComboboxOption[] = [];
|
const options: ComboboxOption[] = targets.map((target) => ({
|
||||||
|
id: target.optionId,
|
||||||
for (const project of projects) {
|
label: target.projectName,
|
||||||
for (const host of project.hosts) {
|
description: `${target.serverName} - ${shortenPath(target.cwd)}`,
|
||||||
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)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { targets, options, targetByOptionId };
|
return { targets, options, targetByOptionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +124,35 @@ function isSelectedModelValidForProviders(input: {
|
|||||||
return provider.modelSelection.rows.some((row) => row.modelId === selectedModel);
|
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({
|
export function ScheduleFormSheet({
|
||||||
serverId,
|
serverId,
|
||||||
visible,
|
visible,
|
||||||
@@ -161,10 +161,26 @@ export function ScheduleFormSheet({
|
|||||||
schedule,
|
schedule,
|
||||||
}: ScheduleFormSheetProps): ReactElement {
|
}: ScheduleFormSheetProps): ReactElement {
|
||||||
const isEdit = mode === "edit";
|
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 { projects } = useProjects();
|
||||||
|
const { agents } = useAggregatedAgents({ includeArchived: true });
|
||||||
const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]);
|
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(
|
const onlineServerIds = useMemo(
|
||||||
() => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))),
|
() => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))),
|
||||||
[projectOptions.targets],
|
[projectOptions.targets],
|
||||||
@@ -197,7 +213,9 @@ export function ScheduleFormSheet({
|
|||||||
setProviderAndModelFromUser,
|
setProviderAndModelFromUser,
|
||||||
clearProviderSelectionFromUser,
|
clearProviderSelectionFromUser,
|
||||||
setModeFromUser,
|
setModeFromUser,
|
||||||
|
setSelectedServerId,
|
||||||
setSelectedServerIdFromUser,
|
setSelectedServerIdFromUser,
|
||||||
|
setWorkingDir,
|
||||||
setWorkingDirFromUser,
|
setWorkingDirFromUser,
|
||||||
modeOptions,
|
modeOptions,
|
||||||
modelSelectorProviders,
|
modelSelectorProviders,
|
||||||
@@ -219,7 +237,10 @@ export function ScheduleFormSheet({
|
|||||||
|
|
||||||
const handleSelectProject = useCallback(
|
const handleSelectProject = useCallback(
|
||||||
(target: ScheduleProjectTarget) => {
|
(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();
|
clearProviderSelectionFromUser();
|
||||||
}
|
}
|
||||||
setSelectedServerIdFromUser(target.serverId);
|
setSelectedServerIdFromUser(target.serverId);
|
||||||
@@ -227,7 +248,7 @@ export function ScheduleFormSheet({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
clearProviderSelectionFromUser,
|
clearProviderSelectionFromUser,
|
||||||
selectedProjectTarget,
|
selectedServerId,
|
||||||
setSelectedServerIdFromUser,
|
setSelectedServerIdFromUser,
|
||||||
setWorkingDirFromUser,
|
setWorkingDirFromUser,
|
||||||
],
|
],
|
||||||
@@ -293,90 +314,127 @@ export function ScheduleFormSheet({
|
|||||||
setCadence(schedule?.cadence ?? DEFAULT_CADENCE);
|
setCadence(schedule?.cadence ?? DEFAULT_CADENCE);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
setFieldResetKey((key) => key + 1);
|
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;
|
wasVisibleRef.current = visible;
|
||||||
}, [visible, schedule]);
|
}, [visible, schedule, isEdit, setSelectedServerId, setWorkingDir]);
|
||||||
|
|
||||||
const promptTrimmed = prompt.trim();
|
const promptTrimmed = prompt.trim();
|
||||||
|
const trimmedWorkingDir = workingDir.trim();
|
||||||
const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null;
|
const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null;
|
||||||
const selectedModelIsValid = isSelectedModelValidForProviders({
|
const selectedModelIsValid = isSelectedModelValidForProviders({
|
||||||
providers: modelSelectorProviders,
|
providers: modelSelectorProviders,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
});
|
});
|
||||||
const canSubmit =
|
const canSubmit = canSubmitScheduleForm({
|
||||||
promptTrimmed.length > 0 &&
|
isAgentTarget,
|
||||||
selectedModelIsValid &&
|
isEdit,
|
||||||
Boolean(selectedProjectTarget) &&
|
promptTrimmed,
|
||||||
cadenceError === null &&
|
cadenceError,
|
||||||
!isSubmitting;
|
isSubmitting,
|
||||||
|
selectedModelIsValid,
|
||||||
|
hasWorkingDir: trimmedWorkingDir.length > 0,
|
||||||
|
hasSelectedProject: Boolean(selectedProjectTarget),
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
// Agent target: the update RPC only accepts name/prompt/cadence/maxRuns.
|
||||||
if (!selectedProvider || !selectedProjectTarget || !promptTrimmed) {
|
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
|
||||||
return;
|
if (!schedule) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
setSubmitError(null);
|
await updateSchedule({
|
||||||
try {
|
id: schedule.id,
|
||||||
await persistFormPreferences();
|
name: name.trim() || null,
|
||||||
const parsedMaxRuns = Number.parseInt(maxRuns, 10);
|
prompt: promptTrimmed,
|
||||||
const maxRunsValue =
|
cadence,
|
||||||
Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null;
|
maxRuns: parseMaxRuns(maxRuns),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}, [cadence, maxRuns, name, promptTrimmed, schedule, updateSchedule]);
|
||||||
|
|
||||||
if (isEdit && schedule) {
|
// New-agent target: submit the current working directory. On edit an untouched
|
||||||
await updateSchedule({
|
// picker leaves this as the stored cwd, so it round-trips unchanged.
|
||||||
id: schedule.id,
|
const submitNewAgent = useCallback(async (): Promise<boolean> => {
|
||||||
name: name.trim() || null,
|
if (!selectedProvider || !trimmedWorkingDir) {
|
||||||
prompt: promptTrimmed,
|
return false;
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
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,
|
cadence,
|
||||||
createSchedule,
|
createSchedule,
|
||||||
isEdit,
|
isEdit,
|
||||||
maxRuns,
|
maxRuns,
|
||||||
name,
|
name,
|
||||||
onClose,
|
|
||||||
persistFormPreferences,
|
persistFormPreferences,
|
||||||
promptTrimmed,
|
promptTrimmed,
|
||||||
schedule,
|
schedule,
|
||||||
selectedMode,
|
selectedMode,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
selectedProjectTarget,
|
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
|
trimmedWorkingDir,
|
||||||
updateSchedule,
|
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(() => {
|
const handleSubmitPress = useCallback(() => {
|
||||||
void handleSubmit();
|
void handleSubmit();
|
||||||
}, [handleSubmit]);
|
}, [handleSubmit]);
|
||||||
@@ -454,34 +512,53 @@ export function ScheduleFormSheet({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.field}>
|
{isAgentTarget ? (
|
||||||
<Text style={styles.label}>Project</Text>
|
<View style={styles.field}>
|
||||||
<ProjectField
|
<Text style={styles.label}>Target</Text>
|
||||||
options={projectOptions.options}
|
<View style={styles.readonlyField} testID="schedule-agent-target">
|
||||||
targetByOptionId={projectOptions.targetByOptionId}
|
<Text style={styles.selectTriggerText} numberOfLines={1}>
|
||||||
value={selectedProjectOptionId}
|
{agentTargetLabel}
|
||||||
selectedTarget={selectedProjectTarget}
|
</Text>
|
||||||
onSelect={handleSelectProject}
|
</View>
|
||||||
/>
|
<Text style={styles.hint}>Runs against this existing agent.</Text>
|
||||||
</View>
|
</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}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>Model</Text>
|
<Text style={styles.label}>Model</Text>
|
||||||
<CombinedModelSelector
|
<CombinedModelSelector
|
||||||
providers={modelSelectorProviders}
|
providers={modelSelectorProviders}
|
||||||
selectedProvider={selectedProvider ?? ""}
|
selectedProvider={selectedProvider ?? ""}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelect={setProviderAndModelFromUser}
|
onSelect={setProviderAndModelFromUser}
|
||||||
isLoading={isAllModelsLoading}
|
isLoading={isAllModelsLoading}
|
||||||
renderTrigger={renderModelTrigger}
|
renderTrigger={renderModelTrigger}
|
||||||
triggerFill
|
triggerFill
|
||||||
serverId={mutationServerId}
|
serverId={mutationServerId}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{modeOptions.length > 0 ? (
|
{modeOptions.length > 0 ? (
|
||||||
<ModeField options={modeOptions} selectedMode={selectedMode} onSelect={setModeFromUser} />
|
<ModeField
|
||||||
) : null}
|
options={modeOptions}
|
||||||
|
selectedMode={selectedMode}
|
||||||
|
onSelect={setModeFromUser}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>Cadence</Text>
|
<Text style={styles.label}>Cadence</Text>
|
||||||
@@ -504,10 +581,6 @@ export function ScheduleFormSheet({
|
|||||||
<Text style={styles.hint}>Leave blank to run indefinitely</Text>
|
<Text style={styles.hint}>Leave blank to run indefinitely</Text>
|
||||||
</View>
|
</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}
|
{submitError ? <Text style={styles.error}>{submitError}</Text> : null}
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
);
|
);
|
||||||
@@ -594,12 +667,15 @@ function ProjectField({
|
|||||||
targetByOptionId,
|
targetByOptionId,
|
||||||
value,
|
value,
|
||||||
selectedTarget,
|
selectedTarget,
|
||||||
|
fallbackCwd,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
options: ComboboxOption[];
|
options: ComboboxOption[];
|
||||||
targetByOptionId: Map<string, ScheduleProjectTarget>;
|
targetByOptionId: Map<string, ScheduleProjectTarget>;
|
||||||
value: string;
|
value: string;
|
||||||
selectedTarget: ScheduleProjectTarget | null;
|
selectedTarget: ScheduleProjectTarget | null;
|
||||||
|
/** Stored cwd for an edited schedule whose path matches no known project. */
|
||||||
|
fallbackCwd: string;
|
||||||
onSelect: (target: ScheduleProjectTarget) => void;
|
onSelect: (target: ScheduleProjectTarget) => void;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
@@ -629,7 +705,13 @@ function ProjectField({
|
|||||||
[open],
|
[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
|
const description = selectedTarget
|
||||||
? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}`
|
? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}`
|
||||||
: null;
|
: null;
|
||||||
@@ -662,7 +744,7 @@ function ProjectField({
|
|||||||
testID="schedule-project-trigger"
|
testID="schedule-project-trigger"
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={selectedTarget ? styles.selectTriggerText : styles.selectTriggerPlaceholder}
|
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
@@ -809,9 +891,20 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
color: theme.colors.destructive,
|
color: theme.colors.palette.red[300],
|
||||||
fontSize: theme.fontSize.xs,
|
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: {
|
selectTrigger: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import { isNative } from "@/constants/platform";
|
|||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
||||||
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
|
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
|
||||||
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
|
|
||||||
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
|
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
|
||||||
@@ -53,43 +55,62 @@ export interface ScheduleRowActions {
|
|||||||
|
|
||||||
interface ScheduleRowProps extends ScheduleRowActions {
|
interface ScheduleRowProps extends ScheduleRowActions {
|
||||||
schedule: ScheduleSummary;
|
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;
|
pending?: ScheduleRowPending;
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProvider(schedule: ScheduleSummary): string | null {
|
function stateBadge(state: ScheduleDerivedState): {
|
||||||
return schedule.target.type === "new-agent" ? schedule.target.config.provider : null;
|
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 {
|
// Meta reads left-to-right as identity → history → future: how often, when it
|
||||||
if (schedule.target.type === "new-agent" && schedule.target.config.model) {
|
// was created, when it last ran, and (only while it can still run) when it runs
|
||||||
return schedule.target.config.model;
|
// 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";
|
if (serverName && !singleHost) {
|
||||||
}
|
parts.unshift(serverName);
|
||||||
|
|
||||||
function statusVariant(status: ScheduleSummary["status"]): "success" | "muted" {
|
|
||||||
return status === "active" ? "success" : "muted";
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: ScheduleSummary["status"]): string {
|
|
||||||
if (status === "active") {
|
|
||||||
return "Active";
|
|
||||||
}
|
}
|
||||||
if (status === "paused") {
|
return parts.join(" · ");
|
||||||
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) || "—";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
/** 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({
|
export function ScheduleRow({
|
||||||
schedule,
|
schedule,
|
||||||
|
targetLabel,
|
||||||
|
provider,
|
||||||
|
state,
|
||||||
|
serverName,
|
||||||
|
singleHost,
|
||||||
pending,
|
pending,
|
||||||
isFirst,
|
isFirst,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -126,15 +152,10 @@ export function ScheduleRow({
|
|||||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||||
|
|
||||||
const provider = resolveProvider(schedule);
|
|
||||||
const title = resolveScheduleTitle(schedule);
|
const title = resolveScheduleTitle(schedule);
|
||||||
const meta = [
|
const badge = stateBadge(state);
|
||||||
resolveModelLabel(schedule),
|
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
|
||||||
formatCadence(schedule.cadence),
|
const canRun = state === "active" || state === "paused";
|
||||||
nextRunLabel(schedule),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" · ");
|
|
||||||
|
|
||||||
const rowStyle = useCallback(
|
const rowStyle = useCallback(
|
||||||
({ pressed }: PressableStateCallbackType) => [
|
({ pressed }: PressableStateCallbackType) => [
|
||||||
@@ -168,6 +189,9 @@ export function ScheduleRow({
|
|||||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={styles.target} numberOfLines={1}>
|
||||||
|
{targetLabel}
|
||||||
|
</Text>
|
||||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||||
{meta}
|
{meta}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -175,12 +199,10 @@ export function ScheduleRow({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.trailing}>
|
<View style={styles.trailing}>
|
||||||
<StatusBadge
|
<StatusBadge label={badge.label} variant={badge.variant} />
|
||||||
label={statusLabel(schedule.status)}
|
|
||||||
variant={statusVariant(schedule.status)}
|
|
||||||
/>
|
|
||||||
<ScheduleKebabMenu
|
<ScheduleKebabMenu
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
|
canRun={canRun}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onPause={onPause}
|
onPause={onPause}
|
||||||
@@ -211,13 +233,19 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElemen
|
|||||||
|
|
||||||
function ScheduleKebabMenu({
|
function ScheduleKebabMenu({
|
||||||
schedule,
|
schedule,
|
||||||
|
canRun,
|
||||||
pending,
|
pending,
|
||||||
onEdit,
|
onEdit,
|
||||||
onPause,
|
onPause,
|
||||||
onResume,
|
onResume,
|
||||||
onRunNow,
|
onRunNow,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: Omit<ScheduleRowProps, "isFirst">): ReactElement {
|
}: Pick<
|
||||||
|
ScheduleRowProps,
|
||||||
|
"schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete"
|
||||||
|
> & {
|
||||||
|
canRun: boolean;
|
||||||
|
}): ReactElement {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
@@ -240,6 +268,7 @@ function ScheduleKebabMenu({
|
|||||||
{schedule.status === "paused" ? (
|
{schedule.status === "paused" ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
leading={resumeLeading}
|
leading={resumeLeading}
|
||||||
|
disabled={!canRun}
|
||||||
status={pending?.resume ? "pending" : "idle"}
|
status={pending?.resume ? "pending" : "idle"}
|
||||||
pendingLabel="Resuming..."
|
pendingLabel="Resuming..."
|
||||||
onSelect={onResume}
|
onSelect={onResume}
|
||||||
@@ -250,7 +279,7 @@ function ScheduleKebabMenu({
|
|||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
leading={pauseLeading}
|
leading={pauseLeading}
|
||||||
disabled={schedule.status === "completed"}
|
disabled={schedule.status === "completed" || !canRun}
|
||||||
status={pending?.pause ? "pending" : "idle"}
|
status={pending?.pause ? "pending" : "idle"}
|
||||||
pendingLabel="Pausing..."
|
pendingLabel="Pausing..."
|
||||||
onSelect={onPause}
|
onSelect={onPause}
|
||||||
@@ -261,6 +290,7 @@ function ScheduleKebabMenu({
|
|||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
leading={runLeading}
|
leading={runLeading}
|
||||||
|
disabled={!canRun}
|
||||||
status={pending?.runNow ? "pending" : "idle"}
|
status={pending?.runNow ? "pending" : "idle"}
|
||||||
pendingLabel="Starting..."
|
pendingLabel="Starting..."
|
||||||
onSelect={onRunNow}
|
onSelect={onRunNow}
|
||||||
@@ -324,6 +354,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
|
target: {
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
trailing: {
|
trailing: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -3,44 +3,48 @@ import { View } from "react-native";
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
|
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
|
||||||
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
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 { settingsStyles } from "@/styles/settings";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { resolveScheduleTitle } from "@/utils/schedule-format";
|
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 {
|
interface SchedulesTableProps {
|
||||||
serverId: string;
|
rows: ScheduleRowView[];
|
||||||
schedules: ScheduleSummary[];
|
|
||||||
/**
|
/**
|
||||||
* The form sheet is owned by the screen (it serves both create and edit and
|
* 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
|
* shares the header's "New schedule" button), so the table delegates edit
|
||||||
* upward rather than mounting a second sheet here.
|
* 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,
|
* The schedules list: a single settings-style card of rows across every
|
||||||
* width-constrained reading column, matching the projects list. Owns row-level
|
* connected host, in a centered, width-constrained reading column matching the
|
||||||
* actions (pause/resume/run/delete via the mutations hook + a destructive
|
* projects list. Rows own their host-scoped mutations (pause/resume/run/delete
|
||||||
* confirm for delete) and delegates editing to the parent.
|
* via the mutations hook + a destructive confirm) and delegate editing upward.
|
||||||
*/
|
*/
|
||||||
export function SchedulesTable({
|
export function SchedulesTable({ rows, onEditSchedule }: SchedulesTableProps): ReactElement {
|
||||||
serverId,
|
|
||||||
schedules,
|
|
||||||
onEditSchedule,
|
|
||||||
}: SchedulesTableProps): ReactElement {
|
|
||||||
const mutations = useScheduleMutations({ serverId });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.listContent} testID="schedules-table">
|
<View style={styles.listContent} testID="schedules-table">
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
{schedules.map((schedule, index) => (
|
{rows.map((row, index) => (
|
||||||
<SchedulesTableRow
|
<SchedulesTableRow
|
||||||
key={schedule.id}
|
key={`${row.schedule.serverId}:${row.schedule.id}`}
|
||||||
schedule={schedule}
|
row={row}
|
||||||
isFirst={index === 0}
|
isFirst={index === 0}
|
||||||
mutations={mutations}
|
|
||||||
onEditSchedule={onEditSchedule}
|
onEditSchedule={onEditSchedule}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -50,28 +54,26 @@ export function SchedulesTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Per-row wrapper owns local in-flight state and binds the table's mutation
|
// Per-row wrapper owns local in-flight state and binds mutations to this
|
||||||
// callbacks to this schedule. Local state keeps pending precise to the acting
|
// schedule's host. Local state keeps pending precise to the acting row even
|
||||||
// row even when several rows are acted on at once (the mutations hook exposes
|
// when several rows are acted on at once (the mutations hook exposes only a
|
||||||
// only a single global pending flag per action).
|
// single global pending flag per action).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type ScheduleMutations = ReturnType<typeof useScheduleMutations>;
|
|
||||||
|
|
||||||
const NO_PENDING: ScheduleRowPending = {};
|
const NO_PENDING: ScheduleRowPending = {};
|
||||||
|
|
||||||
function SchedulesTableRow({
|
function SchedulesTableRow({
|
||||||
schedule,
|
row,
|
||||||
isFirst,
|
isFirst,
|
||||||
mutations,
|
|
||||||
onEditSchedule,
|
onEditSchedule,
|
||||||
}: {
|
}: {
|
||||||
schedule: ScheduleSummary;
|
row: ScheduleRowView;
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
mutations: ScheduleMutations;
|
onEditSchedule: (schedule: AggregatedSchedule) => void;
|
||||||
onEditSchedule: (schedule: ScheduleSummary) => void;
|
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { id } = schedule;
|
const { schedule } = row;
|
||||||
|
const { id, serverId } = schedule;
|
||||||
|
const mutations = useScheduleMutations({ serverId });
|
||||||
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
|
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
|
||||||
|
|
||||||
const runAction = useCallback(
|
const runAction = useCallback(
|
||||||
@@ -127,6 +129,11 @@ function SchedulesTableRow({
|
|||||||
return (
|
return (
|
||||||
<ScheduleRow
|
<ScheduleRow
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
|
targetLabel={row.targetLabel}
|
||||||
|
provider={row.provider}
|
||||||
|
state={row.state}
|
||||||
|
serverName={row.serverName}
|
||||||
|
singleHost={row.singleHost}
|
||||||
isFirst={isFirst}
|
isFirst={isFirst}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import type {
|
|||||||
} from "@getpaseo/client/internal/daemon-client";
|
} from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
|
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";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
|
||||||
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
|
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
|
||||||
@@ -60,11 +63,9 @@ function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapsh
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateScheduleSections(
|
function updateSchedulesData(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
updateSection: (
|
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
|
||||||
section: FetchAggregatedSchedulesResult["sections"][number],
|
|
||||||
) => FetchAggregatedSchedulesResult["sections"][number],
|
|
||||||
): void {
|
): void {
|
||||||
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
|
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
|
||||||
{ queryKey: schedulesQueryBaseKey },
|
{ queryKey: schedulesQueryBaseKey },
|
||||||
@@ -72,7 +73,7 @@ function updateScheduleSections(
|
|||||||
if (!current) {
|
if (!current) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
return { sections: current.sections.map(updateSection) };
|
return { ...current, schedules: updateSchedules(current.schedules) };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -84,26 +85,18 @@ function optimisticallySetStatus(
|
|||||||
status: ScheduleSummary["status"],
|
status: ScheduleSummary["status"],
|
||||||
): void {
|
): void {
|
||||||
const pausedAt = status === "paused" ? new Date().toISOString() : null;
|
const pausedAt = status === "paused" ? new Date().toISOString() : null;
|
||||||
updateScheduleSections(queryClient, (section) =>
|
updateSchedulesData(queryClient, (schedules) =>
|
||||||
section.serverId === serverId
|
schedules.map((schedule) =>
|
||||||
? {
|
schedule.serverId === serverId && schedule.id === id
|
||||||
...section,
|
? { ...schedule, status, pausedAt }
|
||||||
schedules: section.schedules.map((schedule) =>
|
: schedule,
|
||||||
schedule.id === id ? { ...schedule, status, pausedAt } : schedule,
|
),
|
||||||
),
|
|
||||||
}
|
|
||||||
: section,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void {
|
function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void {
|
||||||
updateScheduleSections(queryClient, (section) =>
|
updateSchedulesData(queryClient, (schedules) =>
|
||||||
section.serverId === serverId
|
schedules.filter((schedule) => !(schedule.serverId === serverId && schedule.id === id)),
|
||||||
? {
|
|
||||||
...section,
|
|
||||||
schedules: section.schedules.filter((schedule) => schedule.id !== id),
|
|
||||||
}
|
|
||||||
: section,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
import { useMemo, useSyncExternalStore } from "react";
|
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 { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||||
import {
|
import {
|
||||||
fetchAggregatedSchedules,
|
fetchAggregatedSchedules,
|
||||||
|
type AggregatedSchedule,
|
||||||
|
type ScheduleHostError,
|
||||||
type ScheduleHostInput,
|
type ScheduleHostInput,
|
||||||
type ScheduleHostSection,
|
|
||||||
} from "@/schedules/aggregated-schedules";
|
} 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 const schedulesQueryBaseKey = ["schedules"] as const;
|
||||||
|
|
||||||
export function schedulesQueryKey(hosts: readonly ScheduleHostInput[]) {
|
// Cache identity for the host set. The query also carries the runtime version
|
||||||
return [...schedulesQueryBaseKey, hosts.map((host) => host.serverId).join("|")] as const;
|
// (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 {
|
export interface UseSchedulesResult {
|
||||||
sections: ScheduleHostSection[];
|
schedules: AggregatedSchedule[];
|
||||||
isLoading: boolean;
|
hostErrors: ScheduleHostError[];
|
||||||
|
isInitialLoad: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
@@ -33,23 +40,21 @@ export function useSchedules(): UseSchedulesResult {
|
|||||||
() => runtime.getVersion(),
|
() => runtime.getVersion(),
|
||||||
);
|
);
|
||||||
const hostInputs = useMemo<ScheduleHostInput[]>(
|
const hostInputs = useMemo<ScheduleHostInput[]>(
|
||||||
() =>
|
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
|
||||||
hosts.map((host) => ({
|
|
||||||
serverId: host.serverId,
|
|
||||||
serverName: host.label,
|
|
||||||
})),
|
|
||||||
[hosts],
|
[hosts],
|
||||||
);
|
);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: [...schedulesQueryKey(hostInputs), runtimeVersion] as const,
|
queryKey: [...schedulesQueryKey(hostInputs.map((host) => host.serverId)), runtimeVersion],
|
||||||
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sections: query.data?.sections ?? [],
|
schedules: query.data?.schedules ?? [],
|
||||||
isLoading: query.isLoading,
|
hostErrors: query.data?.hostErrors ?? [],
|
||||||
|
isInitialLoad: query.isLoading && query.data === undefined,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
error: query.error,
|
error: query.error,
|
||||||
refetch: () => {
|
refetch: () => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
import { isNewAgentSchedule } from "@/utils/schedule-format";
|
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
|
|
||||||
|
export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules";
|
||||||
|
|
||||||
export interface ScheduleHostInput {
|
export interface ScheduleHostInput {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
@@ -17,65 +18,76 @@ export interface ScheduleRuntime {
|
|||||||
getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined;
|
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;
|
serverId: string;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
isOnline: boolean;
|
|
||||||
schedules: ScheduleSummary[];
|
|
||||||
error: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FetchAggregatedSchedulesInput {
|
export interface ScheduleHostError {
|
||||||
hosts: ScheduleHostInput[];
|
serverId: string;
|
||||||
runtime: ScheduleRuntime;
|
serverName: string;
|
||||||
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FetchAggregatedSchedulesResult {
|
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(
|
export async function fetchAggregatedSchedules(
|
||||||
input: FetchAggregatedSchedulesInput,
|
input: FetchAggregatedSchedulesInput,
|
||||||
): Promise<FetchAggregatedSchedulesResult> {
|
): Promise<FetchAggregatedSchedulesResult> {
|
||||||
const sections = await Promise.all(
|
const schedules: AggregatedSchedule[] = [];
|
||||||
input.hosts.map(async (host): Promise<ScheduleHostSection> => {
|
const hostErrors: ScheduleHostError[] = [];
|
||||||
|
let connectedAttempts = 0;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
input.hosts.map(async (host) => {
|
||||||
const snapshot = input.runtime.getSnapshot(host.serverId);
|
const snapshot = input.runtime.getSnapshot(host.serverId);
|
||||||
const isOnline = snapshot?.connectionStatus === "online";
|
const isOnline = snapshot?.connectionStatus === "online";
|
||||||
const client = input.runtime.getClient(host.serverId);
|
const client = input.runtime.getClient(host.serverId);
|
||||||
|
|
||||||
if (!client || !isOnline) {
|
if (!client || !isOnline) {
|
||||||
return {
|
return;
|
||||||
serverId: host.serverId,
|
|
||||||
serverName: host.serverName,
|
|
||||||
isOnline,
|
|
||||||
schedules: [],
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
connectedAttempts += 1;
|
||||||
try {
|
try {
|
||||||
const payload = await client.scheduleList();
|
const payload = await client.scheduleList();
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
return {
|
for (const schedule of payload.schedules) {
|
||||||
serverId: host.serverId,
|
schedules.push({ ...schedule, serverId: host.serverId, serverName: host.serverName });
|
||||||
serverName: host.serverName,
|
}
|
||||||
isOnline,
|
|
||||||
schedules: payload.schedules.filter(isNewAgentSchedule),
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
hostErrors.push({
|
||||||
serverId: host.serverId,
|
serverId: host.serverId,
|
||||||
serverName: host.serverName,
|
serverName: host.serverName,
|
||||||
isOnline,
|
message: toErrorMessage(error),
|
||||||
schedules: [],
|
});
|
||||||
error: toErrorMessage(error),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { sections };
|
if (connectedAttempts > 0 && schedules.length === 0 && hostErrors.length === connectedAttempts) {
|
||||||
|
throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { schedules, hostErrors };
|
||||||
}
|
}
|
||||||
|
|||||||
133
packages/app/src/schedules/schedule-derivation.test.ts
Normal file
133
packages/app/src/schedules/schedule-derivation.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
109
packages/app/src/schedules/schedule-derivation.ts
Normal file
109
packages/app/src/schedules/schedule-derivation.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
73
packages/app/src/schedules/schedule-project-targets.test.ts
Normal file
73
packages/app/src/schedules/schedule-project-targets.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
75
packages/app/src/schedules/schedule-project-targets.ts
Normal file
75
packages/app/src/schedules/schedule-project-targets.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { ScrollView, Text, View } from "react-native";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import { Plus } from "lucide-react-native";
|
import { Plus } from "lucide-react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
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 { 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 { Button } from "@/components/ui/button";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
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";
|
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||||
|
|
||||||
type FormState =
|
type FormState =
|
||||||
@@ -16,6 +42,11 @@ type FormState =
|
|||||||
| { mode: "create" }
|
| { mode: "create" }
|
||||||
| { mode: "edit"; serverId: string; schedule: ScheduleSummary };
|
| { 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 {
|
export function SchedulesScreen(): ReactElement {
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
|
|
||||||
@@ -27,20 +58,101 @@ export function SchedulesScreen(): ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SchedulesScreenContent(): 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 [form, setForm] = useState<FormState>({ mode: "closed" });
|
||||||
|
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<ScheduleBucket>("runnable");
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
useEffect(() => {
|
||||||
setForm({ mode: "create" });
|
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) => {
|
const openCreate = useCallback(() => setForm({ mode: "create" }), []);
|
||||||
setForm({ mode: "edit", serverId, schedule });
|
const openEdit = useCallback((schedule: AggregatedSchedule) => {
|
||||||
|
setForm({ mode: "edit", serverId: schedule.serverId, schedule });
|
||||||
}, []);
|
}, []);
|
||||||
|
const closeForm = useCallback(() => setForm({ mode: "closed" }), []);
|
||||||
|
|
||||||
const closeForm = useCallback(() => {
|
const agentsByKey = useMemo(() => {
|
||||||
setForm({ mode: "closed" });
|
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(
|
const headerAction = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -51,14 +163,24 @@ function SchedulesScreenContent(): ReactElement {
|
|||||||
[openCreate],
|
[openCreate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showLoadError = isError && schedules.length === 0;
|
||||||
|
const showHostFilter = hosts.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<MenuHeader title="Schedules" rightContent={headerAction} />
|
<MenuHeader title="Schedules" rightContent={headerAction} />
|
||||||
<SchedulesBody
|
<SchedulesScreenBody
|
||||||
sections={sections}
|
rows={visibleRows}
|
||||||
isLoading={isLoading}
|
hostErrors={hostErrors}
|
||||||
isError={isError}
|
hasSchedules={schedules.length > 0}
|
||||||
error={error}
|
isInitialLoad={isInitialLoad}
|
||||||
|
showLoadError={showLoadError}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onStatusFilterChange={setStatusFilter}
|
||||||
|
showHostFilter={showHostFilter}
|
||||||
|
hosts={hosts}
|
||||||
|
selectedHost={selectedHost}
|
||||||
|
onSelectHost={setSelectedHost}
|
||||||
onRetry={refetch}
|
onRetry={refetch}
|
||||||
onCreate={openCreate}
|
onCreate={openCreate}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
@@ -74,24 +196,38 @@ function SchedulesScreenContent(): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SchedulesBody({
|
function SchedulesScreenBody({
|
||||||
sections,
|
rows,
|
||||||
isLoading,
|
hostErrors,
|
||||||
isError,
|
hasSchedules,
|
||||||
error,
|
isInitialLoad,
|
||||||
|
showLoadError,
|
||||||
|
statusFilter,
|
||||||
|
onStatusFilterChange,
|
||||||
|
showHostFilter,
|
||||||
|
hosts,
|
||||||
|
selectedHost,
|
||||||
|
onSelectHost,
|
||||||
onRetry,
|
onRetry,
|
||||||
onCreate,
|
onCreate,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
sections: ScheduleHostSection[];
|
rows: ScheduleRowView[];
|
||||||
isLoading: boolean;
|
hostErrors: ScheduleHostError[];
|
||||||
isError: boolean;
|
hasSchedules: boolean;
|
||||||
error: Error | null;
|
isInitialLoad: boolean;
|
||||||
|
showLoadError: boolean;
|
||||||
|
statusFilter: ScheduleBucket;
|
||||||
|
onStatusFilterChange: (value: ScheduleBucket) => void;
|
||||||
|
showHostFilter: boolean;
|
||||||
|
hosts: ReturnType<typeof useHosts>;
|
||||||
|
selectedHost: string;
|
||||||
|
onSelectHost: (serverId: string) => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
|
onEdit: (schedule: AggregatedSchedule) => void;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
if (isLoading) {
|
if (isInitialLoad) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
<LoadingSpinner size="large" color={styles.spinner.color} />
|
<LoadingSpinner size="large" color={styles.spinner.color} />
|
||||||
@@ -99,85 +235,95 @@ function SchedulesBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (showLoadError) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centered}>
|
<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">
|
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
|
||||||
Retry
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sections.length === 0) {
|
if (!hasSchedules) {
|
||||||
return (
|
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>
|
<Text style={styles.message}>No schedules yet</Text>
|
||||||
<Button leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
|
<Button variant="ghost" leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
|
||||||
New schedule
|
Create a schedule
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const emptyFilterText = statusFilter === "ended" ? "No ended schedules" : "No active schedules";
|
||||||
<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";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.section} testID={`schedules-section-${section.serverId}`}>
|
<View style={styles.body}>
|
||||||
<Text style={styles.sectionTitle} testID={`schedules-section-title-${section.serverId}`}>
|
<View style={styles.filterRow}>
|
||||||
{section.serverName}
|
{showHostFilter ? (
|
||||||
</Text>
|
<HostFilter
|
||||||
{section.error ? <Text style={styles.sectionError}>{section.error}</Text> : null}
|
hosts={hosts}
|
||||||
{section.schedules.length > 0 ? (
|
selectedHost={selectedHost}
|
||||||
<SchedulesTable
|
onSelectHost={onSelectHost}
|
||||||
serverId={section.serverId}
|
triggerTestID="schedules-host-filter-trigger"
|
||||||
schedules={section.schedules}
|
/>
|
||||||
onEditSchedule={handleEdit}
|
) : null}
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={onStatusFilterChange}
|
||||||
|
options={STATUS_FILTER_OPTIONS}
|
||||||
|
testID="schedules-status-filter"
|
||||||
/>
|
/>
|
||||||
) : null}
|
</View>
|
||||||
{section.schedules.length === 0 && !section.error ? (
|
<ScrollView
|
||||||
<View style={styles.sectionEmpty}>
|
style={styles.scroll}
|
||||||
<Text style={styles.sectionEmptyText}>{emptyMessage}</Text>
|
contentContainerStyle={styles.scrollContent}
|
||||||
</View>
|
showsVerticalScrollIndicator={false}
|
||||||
) : null}
|
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>
|
</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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
|
body: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
centered: {
|
centered: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -185,43 +331,52 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[6],
|
gap: theme.spacing[6],
|
||||||
padding: 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: {
|
scroll: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[3],
|
||||||
paddingTop: theme.spacing[4],
|
paddingTop: theme.spacing[4],
|
||||||
paddingBottom: theme.spacing[6],
|
paddingBottom: theme.spacing[6],
|
||||||
},
|
},
|
||||||
section: {
|
errorsBannerWrap: {
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: 720,
|
maxWidth: CONTENT_MAX_WIDTH,
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
paddingHorizontal: theme.spacing[4],
|
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%",
|
width: "100%",
|
||||||
maxWidth: 720,
|
maxWidth: CONTENT_MAX_WIDTH,
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
color: theme.colors.destructive,
|
paddingVertical: theme.spacing[6],
|
||||||
fontSize: theme.fontSize.sm,
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
sectionEmpty: {
|
filterEmptyText: {
|
||||||
width: "100%",
|
|
||||||
maxWidth: 720,
|
|
||||||
alignSelf: "center",
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
},
|
|
||||||
sectionEmptyText: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
|
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||||
import { Pressable, type PressableStateCallbackType, View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { AgentList } from "@/components/agent-list";
|
import { AgentList } from "@/components/agent-list";
|
||||||
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
|
import { HostFilter } from "@/components/hosts/host-filter";
|
||||||
import {
|
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
|
||||||
ALL_HOSTS_OPTION_ID,
|
|
||||||
getHostPickerLabel,
|
|
||||||
HostPicker,
|
|
||||||
} from "@/components/hosts/host-picker";
|
|
||||||
import { useAgentHistory } from "@/hooks/use-agent-history";
|
import { useAgentHistory } from "@/hooks/use-agent-history";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { type HostProfile } from "@/types/host-connection";
|
|
||||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
export function SessionsScreen() {
|
export function SessionsScreen() {
|
||||||
@@ -30,71 +25,6 @@ export function SessionsScreen() {
|
|||||||
return <SessionsScreenContent />;
|
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() {
|
function SessionsScreenContent() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -152,10 +82,11 @@ function SessionsScreenContent() {
|
|||||||
<MenuHeader title={t("sessions.title")} />
|
<MenuHeader title={t("sessions.title")} />
|
||||||
{showHostFilter ? (
|
{showHostFilter ? (
|
||||||
<View style={styles.filterContainer}>
|
<View style={styles.filterContainer}>
|
||||||
<SessionsHostFilter
|
<HostFilter
|
||||||
hosts={hosts}
|
hosts={hosts}
|
||||||
selectedHost={selectedHost}
|
selectedHost={selectedHost}
|
||||||
onSelectHost={setSelectedHost}
|
onSelectHost={setSelectedHost}
|
||||||
|
triggerTestID="sessions-host-filter-trigger"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -207,32 +138,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
paddingTop: theme.spacing[4],
|
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: {
|
emptyContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -2803,12 +2803,14 @@ describe("create_schedule MCP tool", () => {
|
|||||||
|
|
||||||
it("requires provider for schedules", async () => {
|
it("requires provider for schedules", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
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({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { create } as unknown as ScheduleService,
|
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_schedule");
|
const tool = registeredTool(server, "create_schedule");
|
||||||
@@ -2820,17 +2822,19 @@ describe("create_schedule MCP tool", () => {
|
|||||||
name: "Default schedule",
|
name: "Default schedule",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("provider is required when target is new-agent");
|
).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 () => {
|
it("keeps create_schedule provider overrides compatible with provider and provider/model forms", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
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({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { create } as unknown as ScheduleService,
|
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_schedule");
|
const tool = registeredTool(server, "create_schedule");
|
||||||
@@ -2846,7 +2850,7 @@ describe("create_schedule MCP tool", () => {
|
|||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(create).toHaveBeenNthCalledWith(
|
expect(createOrReplace).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
target: {
|
target: {
|
||||||
@@ -2858,7 +2862,7 @@ describe("create_schedule MCP tool", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(create).toHaveBeenNthCalledWith(
|
expect(createOrReplace).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
target: {
|
target: {
|
||||||
@@ -2888,12 +2892,14 @@ describe("create_schedule MCP tool", () => {
|
|||||||
featureValues: { auto_accept: true },
|
featureValues: { auto_accept: true },
|
||||||
},
|
},
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
|
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||||
|
createStoredSchedule(input),
|
||||||
|
);
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { create } as unknown as ScheduleService,
|
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||||
callerAgentId: "parent-agent",
|
callerAgentId: "parent-agent",
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
@@ -2914,14 +2920,14 @@ describe("create_schedule MCP tool", () => {
|
|||||||
|
|
||||||
it("passes timezone through cron create_schedule input", async () => {
|
it("passes timezone through cron create_schedule input", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
|
const createOrReplace = vi.fn(async (scheduleInput: CreateScheduleInput) =>
|
||||||
createStoredSchedule(scheduleInput),
|
createStoredSchedule(scheduleInput),
|
||||||
);
|
);
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { create } as unknown as ScheduleService,
|
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_schedule");
|
const tool = registeredTool(server, "create_schedule");
|
||||||
@@ -2933,7 +2939,7 @@ describe("create_schedule MCP tool", () => {
|
|||||||
provider: "codex",
|
provider: "codex",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(create).toHaveBeenCalledWith(
|
expect(createOrReplace).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
cadence: {
|
cadence: {
|
||||||
type: "cron",
|
type: "cron",
|
||||||
@@ -2946,12 +2952,12 @@ describe("create_schedule MCP tool", () => {
|
|||||||
|
|
||||||
it("rejects removed create_schedule every input", async () => {
|
it("rejects removed create_schedule every input", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const create = vi.fn();
|
const createOrReplace = vi.fn();
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { create } as unknown as ScheduleService,
|
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_schedule");
|
const tool = registeredTool(server, "create_schedule");
|
||||||
@@ -2963,17 +2969,17 @@ describe("create_schedule MCP tool", () => {
|
|||||||
});
|
});
|
||||||
expect(parsed.success).toBe(false);
|
expect(parsed.success).toBe(false);
|
||||||
|
|
||||||
expect(create).not.toHaveBeenCalled();
|
expect(createOrReplace).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects create_schedule without cron", async () => {
|
it("rejects create_schedule without cron", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const create = vi.fn();
|
const createOrReplace = vi.fn();
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { create } as unknown as ScheduleService,
|
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_schedule");
|
const tool = registeredTool(server, "create_schedule");
|
||||||
@@ -2985,17 +2991,17 @@ describe("create_schedule MCP tool", () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow(/cron/);
|
).rejects.toThrow(/cron/);
|
||||||
|
|
||||||
expect(create).not.toHaveBeenCalled();
|
expect(createOrReplace).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["", " "])("rejects create_schedule blank timezone %#", async (timezone) => {
|
it.each(["", " "])("rejects create_schedule blank timezone %#", async (timezone) => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const create = vi.fn();
|
const createOrReplace = vi.fn();
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { create } as unknown as ScheduleService,
|
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_schedule");
|
const tool = registeredTool(server, "create_schedule");
|
||||||
@@ -3009,7 +3015,7 @@ describe("create_schedule MCP tool", () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
|
|
||||||
expect(create).not.toHaveBeenCalled();
|
expect(createOrReplace).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3027,12 +3033,14 @@ describe("create_heartbeat MCP tool", () => {
|
|||||||
availableModes: [],
|
availableModes: [],
|
||||||
config: { title: "Parent agent" },
|
config: { title: "Parent agent" },
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
|
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||||
|
createStoredSchedule(input),
|
||||||
|
);
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { create } as unknown as ScheduleService,
|
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||||
callerAgentId: "parent-agent",
|
callerAgentId: "parent-agent",
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
@@ -3045,7 +3053,7 @@ describe("create_heartbeat MCP tool", () => {
|
|||||||
name: "status heartbeat",
|
name: "status heartbeat",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(create).toHaveBeenCalledWith(
|
expect(createOrReplace).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
prompt: "check status",
|
prompt: "check status",
|
||||||
cadence: {
|
cadence: {
|
||||||
@@ -3061,12 +3069,12 @@ describe("create_heartbeat MCP tool", () => {
|
|||||||
|
|
||||||
it("requires an agent-scoped session", async () => {
|
it("requires an agent-scoped session", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const create = vi.fn();
|
const createOrReplace = vi.fn();
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
scheduleService: { create } as unknown as ScheduleService,
|
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "create_heartbeat");
|
const tool = registeredTool(server, "create_heartbeat");
|
||||||
@@ -3078,7 +3086,7 @@ describe("create_heartbeat MCP tool", () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow("create_heartbeat requires an agent-scoped session");
|
).rejects.toThrow("create_heartbeat requires an agent-scoped session");
|
||||||
|
|
||||||
expect(create).not.toHaveBeenCalled();
|
expect(createOrReplace).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2009,7 +2009,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expiresAt = buildScheduleExpiry(expiresIn);
|
const expiresAt = buildScheduleExpiry(expiresIn);
|
||||||
const schedule = await scheduleService.create({
|
const schedule = await scheduleService.createOrReplace({
|
||||||
prompt: prompt.trim(),
|
prompt: prompt.trim(),
|
||||||
cadence: buildCronScheduleCadence({
|
cadence: buildCronScheduleCadence({
|
||||||
cron,
|
cron,
|
||||||
@@ -2058,7 +2058,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
|||||||
resolveCallerAgent();
|
resolveCallerAgent();
|
||||||
|
|
||||||
const expiresAt = buildScheduleExpiry(expiresIn);
|
const expiresAt = buildScheduleExpiry(expiresIn);
|
||||||
const schedule = await scheduleService.create({
|
const schedule = await scheduleService.createOrReplace({
|
||||||
prompt: prompt.trim(),
|
prompt: prompt.trim(),
|
||||||
cadence: buildCronScheduleCadence({
|
cadence: buildCronScheduleCadence({
|
||||||
cron,
|
cron,
|
||||||
|
|||||||
@@ -818,9 +818,9 @@ export async function createPaseoDaemon(
|
|||||||
await scheduleService.start();
|
await scheduleService.start();
|
||||||
agentManager.setAgentArchivedCallback(async (agentId) => {
|
agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||||
try {
|
try {
|
||||||
await scheduleService.deleteForAgent(agentId);
|
await scheduleService.completeForAgent(agentId);
|
||||||
} catch (error) {
|
} 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");
|
logger.info({ elapsed: elapsed() }, "Schedule service initialized");
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import type {
|
|||||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.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";
|
import type { ScheduleExecutionResult, StoredSchedule } from "@getpaseo/protocol/schedule/types";
|
||||||
|
|
||||||
interface ScheduleServiceInternals {
|
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", () => {
|
describe("ScheduleService", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
let agentStorage: AgentStorage;
|
let agentStorage: AgentStorage;
|
||||||
@@ -1266,7 +1296,7 @@ describe("ScheduleService", () => {
|
|||||||
await expect(service.runOnce(created.id)).rejects.toThrow("already completed");
|
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({
|
const service = new ScheduleService({
|
||||||
paseoHome: tempDir,
|
paseoHome: tempDir,
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
@@ -1299,12 +1329,577 @@ describe("ScheduleService", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleted = await service.deleteForAgent(targetAgentId);
|
now = new Date("2026-01-01T00:05:00.000Z");
|
||||||
expect(deleted).toBe(1);
|
const completed = await service.completeForAgent(targetAgentId);
|
||||||
|
expect(completed).toBe(1);
|
||||||
|
|
||||||
const remaining = await service.list();
|
const remaining = await service.list();
|
||||||
const remainingIds = remaining.map((schedule) => schedule.id).sort();
|
expect(remaining.map((schedule) => schedule.id).sort()).toEqual(
|
||||||
expect(remainingIds).toEqual([otherTargeted.id, newAgentSchedule.id].sort());
|
[targeted.id, otherTargeted.id, newAgentSchedule.id].sort(),
|
||||||
expect(remainingIds).not.toContain(targeted.id);
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { stat } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { AgentManager } from "../agent/agent-manager.js";
|
import { AgentManager } from "../agent/agent-manager.js";
|
||||||
@@ -27,6 +28,16 @@ import type {
|
|||||||
|
|
||||||
const SCHEDULE_TICK_INTERVAL_MS = 1000;
|
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 {
|
function trimOptionalName(value: string | null | undefined): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -112,6 +123,35 @@ function shouldCompleteSchedule(schedule: StoredSchedule, now: Date): boolean {
|
|||||||
return countCompletedRuns(schedule) >= schedule.maxRuns;
|
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 {
|
function completeSchedule(schedule: StoredSchedule, now: Date): StoredSchedule {
|
||||||
return {
|
return {
|
||||||
...schedule,
|
...schedule,
|
||||||
@@ -177,6 +217,7 @@ export class ScheduleService {
|
|||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
await this.recoverInterruptedRuns();
|
await this.recoverInterruptedRuns();
|
||||||
|
await this.sweepOrphanedSchedules();
|
||||||
if (this.tickTimer) {
|
if (this.tickTimer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -220,6 +261,44 @@ export class ScheduleService {
|
|||||||
return schedule;
|
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[]> {
|
async list(): Promise<StoredSchedule[]> {
|
||||||
return this.store.list();
|
return this.store.list();
|
||||||
}
|
}
|
||||||
@@ -321,26 +400,30 @@ export class ScheduleService {
|
|||||||
await this.store.delete(id);
|
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 schedules = await this.store.list();
|
||||||
const matches = schedules.filter(
|
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(
|
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()) {
|
for (const [index, result] of results.entries()) {
|
||||||
if (result.status === "fulfilled") {
|
if (result.status === "fulfilled") {
|
||||||
deleted += 1;
|
completed += 1;
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
{ err: result.reason, scheduleId: matches[index].id, agentId },
|
{ 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> {
|
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(
|
private async runSchedule(
|
||||||
schedule: StoredSchedule,
|
schedule: StoredSchedule,
|
||||||
now: Date,
|
now: Date,
|
||||||
@@ -454,6 +557,7 @@ export class ScheduleService {
|
|||||||
agentId: result.agentId,
|
agentId: result.agentId,
|
||||||
output: result.output,
|
output: result.output,
|
||||||
error: null,
|
error: null,
|
||||||
|
targetGone: false,
|
||||||
manual,
|
manual,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -464,6 +568,7 @@ export class ScheduleService {
|
|||||||
agentId: null,
|
agentId: null,
|
||||||
output: null,
|
output: null,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
targetGone: error instanceof ScheduleTargetGoneError,
|
||||||
manual,
|
manual,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -478,6 +583,7 @@ export class ScheduleService {
|
|||||||
agentId: string | null;
|
agentId: string | null;
|
||||||
output: string | null;
|
output: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
targetGone: boolean;
|
||||||
manual: boolean;
|
manual: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const schedule = await this.inspect(params.scheduleId);
|
const schedule = await this.inspect(params.scheduleId);
|
||||||
@@ -501,7 +607,14 @@ export class ScheduleService {
|
|||||||
updatedAt: now.toISOString(),
|
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.
|
// Manual one-shot runs do not advance the cadence or recompute completion.
|
||||||
} else if (shouldCompleteSchedule(updated, now)) {
|
} else if (shouldCompleteSchedule(updated, now)) {
|
||||||
updated = completeSchedule(updated, now);
|
updated = completeSchedule(updated, now);
|
||||||
@@ -532,8 +645,11 @@ export class ScheduleService {
|
|||||||
if (schedule.target.type === "agent") {
|
if (schedule.target.type === "agent") {
|
||||||
const wrappedPrompt = formatSystemNotificationPrompt(buildScheduleFireBody(schedule, runId));
|
const wrappedPrompt = formatSystemNotificationPrompt(buildScheduleFireBody(schedule, runId));
|
||||||
const record = await this.agentStorage.get(schedule.target.agentId);
|
const record = await this.agentStorage.get(schedule.target.agentId);
|
||||||
if (record?.archivedAt) {
|
if (!record) {
|
||||||
throw new Error(`Agent ${schedule.target.agentId} is archived`);
|
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, {
|
const agent = await ensureAgentLoaded(schedule.target.agentId, {
|
||||||
@@ -557,6 +673,14 @@ export class ScheduleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetConfig = schedule.target.config;
|
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
|
const resolvedUnattendedConfig = targetConfig.modeId
|
||||||
? { modeId: targetConfig.modeId, featureValues: targetConfig.featureValues }
|
? { modeId: targetConfig.modeId, featureValues: targetConfig.featureValues }
|
||||||
: await this.resolveProviderCreateConfig({
|
: await this.resolveProviderCreateConfig({
|
||||||
|
|||||||
Reference in New Issue
Block a user