Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 9eec33773a refactor(config): use schema stripping for persisted fields 2026-07-02 18:44:17 +02:00
Mohamed Boudra 5616c6af6a fix(config): tolerate unknown persisted fields
Config files can be shared across checkouts with different schema versions. Strip unrecognized keys on load so read paths like daemon status do not fail before they can report state; known malformed fields still fail validation.
2026-07-02 18:39:46 +02:00
Mohamed Boudra 58fce6622b Merge branch 'main' of github.com:getpaseo/paseo 2026-07-02 18:22:13 +02:00
Mohamed Boudra 27f1f1d207 fix(app): align schedules screen shell with app conventions
Move the New schedule button out of the screen header (the only screen
using MenuHeader rightContent) into the content filter row, and match the
History screen's full-width layout instead of the 720px centered column.

Tighten the shared sm SegmentedControl and Button vertical padding so the
status switcher and button sit flush with the host-filter pill; this is a
global sm change and also affects settings and the schedule form sheet.
2026-07-02 15:41:08 +02:00
Christoph LeiterandClaude Opus 4.8 807d0d6d69 fix(app): show New workspace action on non-git sidebar projects (#1857)
* fix(app): show New workspace action on non-git sidebar projects

The per-project "New workspace" affordance in the sidebar (the project-
header + button and the empty-project ghost row) was gated on
host.canCreateWorktree, i.e. projectKind === "git". Non-git projects
(non_git / directory) therefore showed no way to add a workspace, even
though a host with the workspaceMultiplicity capability can create
additional local workspaces for them.

Thread the per-host workspaceMultiplicity flag into the sidebar project
row model and show the affordance when canCreateWorktree ||
supportsWorkspaceMultiplicity, matching the gate already used by the
global "New workspace" button and the Cmd+N handler. Rename the internal
trailing-action kind new_worktree -> new_workspace (a client-only UI
model, not a wire type) to reflect that it now also covers non-git
workspaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(app): expect the per-row New workspace icon on non-git projects

The sidebar "+" now shows for non-git projects on a multiplicity-capable
host, so the Model B sidebar spec no longer asserts the non-git project
has zero new-worktree icons — it now expects the icon, like git projects.
Also refresh the now-stale "no new-worktree icon" comments in the
entry-points spec (the picker is still one valid entry point).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:15:10 +08:00
Mohamed Boudra 6513b56571 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
2026-07-02 12:05:35 +02:00
31 changed files with 2187 additions and 640 deletions
+3
View File
@@ -195,6 +195,9 @@ Single file, validated with `PersistedConfigSchema`.
All fields are optional with sensible defaults.
Config parsing strips unrecognized object keys so a config written by a newer daemon does not brick
older read paths such as `paseo daemon status`. Malformed known fields still fail validation.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
+7 -7
View File
@@ -13,12 +13,12 @@ import { getServerId } from "./helpers/server-id";
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B entry points into the New Workspace screen. The per-project
// "+ New workspace" sidebar row is gone; the surviving entries are the global
// button (universal) and each git project's own new-worktree icon (preselects
// that project). These specs prove the global entry opens the screen, the
// project icon preselects the right project across the reused 'new' screen, and
// non-git projects never offer the worktree Isolation control.
// Model B entry points into the New Workspace screen. The surviving entries are
// the global button (universal) and each project's per-row New workspace icon
// (preselects that project) — shown for git projects and for non-git projects on
// a multiplicity-capable host. These specs prove the global entry opens the
// screen, the project icon preselects the right project across the reused 'new'
// screen, and non-git projects never offer the worktree Isolation control.
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectKey}`);
@@ -215,7 +215,7 @@ test.describe("New workspace entry points", () => {
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
// Open New Workspace for the non-git project via the global button, then
// select it in the picker (its row has no new-worktree icon).
// select it in the picker (the per-row icon would preselect it too).
await openGlobalNewWorkspaceComposer(page);
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
@@ -76,7 +76,7 @@ test.describe("Schedules", () => {
await page.goto(buildSchedulesRoute());
const row = page.getByTestId(`schedule-row-${scheduleId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText("ten-second-stream");
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
await row.click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
@@ -5,7 +5,6 @@ import {
buildFakeScheduleHostWorkspace,
installFakeScheduleHost,
} from "./helpers/schedule-fake-host";
import { getServerId } from "./helpers/server-id";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { buildSchedulesRoute } from "../src/utils/host-routes";
@@ -82,9 +81,9 @@ test.describe("Schedules project target", () => {
await page.getByRole("button", { name: "Schedules" }).click();
await expect(page).toHaveURL(/\/schedules$/);
await expect(page).not.toHaveURL(/\/h\//);
await expect(page.getByTestId(`schedules-section-${getServerId()}`)).toBeVisible();
await expect(page.getByTestId("schedules-empty")).toBeVisible();
await page.getByRole("button", { name: "New schedule" }).click();
await page.getByTestId("schedules-empty-new").click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
@@ -127,10 +126,8 @@ test.describe("Schedules project target", () => {
label: "Fake host",
port: fakePort,
});
await expect(page.getByTestId(`schedules-section-${fakeHost.serverId}`)).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "New schedule" }).click();
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("schedules-empty-new").click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: /select project/i }).click();
+8 -5
View File
@@ -38,7 +38,7 @@ async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Prom
test.describe("Model B sidebar shape", () => {
test.describe.configure({ timeout: 180_000 });
test("git and non-git projects both render as expandable parents; git keeps a per-row new-worktree icon, the global button covers both", async ({
test("git and non-git projects both render as expandable parents, both show a per-row New workspace icon, and the global button covers both", async ({
page,
}) => {
const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" });
@@ -62,14 +62,17 @@ test.describe("Model B sidebar shape", () => {
await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 });
// The per-project "+ New workspace" row is gone. The git project keeps a
// per-row new-worktree icon (revealed on hover); the non-git project has
// none, since worktree creation needs a git checkout.
// Both projects show a per-row New workspace icon (revealed on hover): the
// git project can branch off a worktree, and the non-git project can add
// another workspace because the host supports workspaceMultiplicity.
await projectRow(page, gitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
timeout: 30_000,
});
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toHaveCount(0);
await projectRow(page, nonGitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
timeout: 30_000,
});
// The global new-workspace button is the universal entry — present for both
// kinds regardless of their per-row affordance.
@@ -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 { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { isWeb } from "@/constants/platform";
import {
describeCron,
everyMsToParts,
@@ -200,7 +199,6 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
value={intervalUnit}
onValueChange={handleUnitChange}
options={UNIT_OPTIONS}
style={styles.unitControl}
testID="cadence-interval-unit"
/>
</View>
@@ -283,8 +281,6 @@ function CronPresetChip({
);
}
const MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo";
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
@@ -308,15 +304,10 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
// Both cadence segmented controls hug their options and stand at the form's
// field height, so the interval row reads as input + toggle rather than a
// full-width track with the controls floating inside it.
// The mode toggle hugs its options at the left rather than stretching to a
// full-width track; the interval row then reads as input + toggle.
modeControl: {
alignSelf: "flex-start",
height: 44,
},
unitControl: {
height: 44,
},
presetRow: {
flexDirection: "row",
@@ -337,9 +328,11 @@ const styles = StyleSheet.create((theme) => ({
chipHover: {
backgroundColor: theme.colors.surface3,
},
// Selected preset reads as a chosen surface, not a second accent fill
// competing with the sheet's primary CTA.
chipSelected: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
chipLabel: {
fontSize: theme.fontSize.xs,
@@ -347,7 +340,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
chipLabelSelected: {
color: theme.colors.accentForeground,
color: theme.colors.foreground,
},
cronInput: {
minHeight: 44,
@@ -358,7 +351,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.sm,
fontFamily: MONOSPACE_FONT,
fontFamily: theme.fontFamily.mono,
},
preview: {
fontSize: theme.fontSize.xs,
@@ -370,6 +363,6 @@ const styles = StyleSheet.create((theme) => ({
},
error: {
fontSize: theme.fontSize.xs,
color: theme.colors.destructive,
color: theme.colors.palette.red[300],
},
}));
@@ -24,7 +24,13 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CadenceEditor } from "@/components/schedules/cadence-editor";
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import {
buildScheduleProjectTargets,
PROJECT_OPTION_PREFIX,
type ScheduleProjectTarget,
} from "@/schedules/schedule-project-targets";
import { validateCron } from "@/utils/schedule-format";
import { toErrorMessage } from "@/utils/error-messages";
import { shortenPath } from "@/utils/shorten-path";
@@ -32,7 +38,6 @@ import type { ProjectSummary } from "@/utils/projects";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 };
const PROJECT_OPTION_PREFIX = "project:";
export interface ScheduleFormSheetProps {
serverId?: string;
@@ -42,15 +47,6 @@ export interface ScheduleFormSheetProps {
schedule?: ScheduleSummary;
}
interface ScheduleProjectTarget {
optionId: string;
serverId: string;
serverName: string;
projectKey: string;
projectName: string;
cwd: string;
}
interface ScheduleProjectOptions {
targets: ScheduleProjectTarget[];
options: ComboboxOption[];
@@ -79,44 +75,19 @@ function buildInitialValues(schedule: ScheduleSummary | undefined): FormInitialV
};
}
function buildProjectOptionId(serverId: string, projectKey: string): string {
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
}
function buildProjectOptionTestId(optionId: string): string {
const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, "");
return `schedule-project-option-${targetKey}`;
}
function buildScheduleProjectOptions(projects: readonly ProjectSummary[]): ScheduleProjectOptions {
const targets: ScheduleProjectTarget[] = [];
const targetByOptionId = new Map<string, ScheduleProjectTarget>();
const options: ComboboxOption[] = [];
for (const project of projects) {
for (const host of project.hosts) {
const cwd = host.repoRoot.trim();
if (!host.isOnline || !cwd) {
continue;
}
const target: ScheduleProjectTarget = {
optionId: buildProjectOptionId(host.serverId, project.projectKey),
serverId: host.serverId,
serverName: host.serverName,
projectKey: project.projectKey,
projectName: project.projectName,
cwd,
};
targets.push(target);
targetByOptionId.set(target.optionId, target);
options.push({
id: target.optionId,
label: target.projectName,
description: `${target.serverName} - ${shortenPath(cwd)}`,
});
}
}
const targets = buildScheduleProjectTargets(projects);
const targetByOptionId = new Map(targets.map((target) => [target.optionId, target]));
const options: ComboboxOption[] = targets.map((target) => ({
id: target.optionId,
label: target.projectName,
description: `${target.serverName} - ${shortenPath(target.cwd)}`,
}));
return { targets, options, targetByOptionId };
}
@@ -153,6 +124,35 @@ function isSelectedModelValidForProviders(input: {
return provider.modelSelection.rows.some((row) => row.modelId === selectedModel);
}
function parseMaxRuns(raw: string): number | null {
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function canSubmitScheduleForm(input: {
isAgentTarget: boolean;
isEdit: boolean;
promptTrimmed: string;
cadenceError: string | null;
isSubmitting: boolean;
selectedModelIsValid: boolean;
hasWorkingDir: boolean;
hasSelectedProject: boolean;
}): boolean {
if (input.promptTrimmed.length === 0 || input.cadenceError !== null || input.isSubmitting) {
return false;
}
// Agent targets only edit name/prompt/cadence. New-agent edit accepts any
// non-empty stored cwd; create requires a matched project.
if (input.isAgentTarget) {
return true;
}
if (!input.selectedModelIsValid) {
return false;
}
return input.isEdit ? input.hasWorkingDir : input.hasSelectedProject;
}
export function ScheduleFormSheet({
serverId,
visible,
@@ -161,10 +161,26 @@ export function ScheduleFormSheet({
schedule,
}: ScheduleFormSheetProps): ReactElement {
const isEdit = mode === "edit";
const editConfig = newAgentConfig(schedule);
// Agent-targeted schedules can only update name/prompt/cadence/maxRuns
// (service.ts rejects newAgentConfig for them), so the form drops the
// project/model/mode pickers and shows the target agent read-only instead.
const isAgentTarget = isEdit && schedule?.target.type === "agent";
const { projects } = useProjects();
const { agents } = useAggregatedAgents({ includeArchived: true });
const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]);
const agentTargetLabel = useMemo(() => {
if (!schedule || schedule.target.type !== "agent") {
return null;
}
const { agentId } = schedule.target;
const agent = agents.find((entry) => entry.serverId === serverId && entry.id === agentId);
if (!agent) {
return "Agent unavailable";
}
return agent.title?.trim() || "Untitled agent";
}, [agents, schedule, serverId]);
const onlineServerIds = useMemo(
() => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))),
[projectOptions.targets],
@@ -197,7 +213,9 @@ export function ScheduleFormSheet({
setProviderAndModelFromUser,
clearProviderSelectionFromUser,
setModeFromUser,
setSelectedServerId,
setSelectedServerIdFromUser,
setWorkingDir,
setWorkingDirFromUser,
modeOptions,
modelSelectorProviders,
@@ -219,7 +237,10 @@ export function ScheduleFormSheet({
const handleSelectProject = useCallback(
(target: ScheduleProjectTarget) => {
if (selectedProjectTarget && selectedProjectTarget.serverId !== target.serverId) {
// Compare against the current server, not the matched target: an unmatched
// stored cwd has no target but still lives on a host, and switching hosts
// must still clear a provider/model that may not exist on the new one.
if (selectedServerId && selectedServerId !== target.serverId) {
clearProviderSelectionFromUser();
}
setSelectedServerIdFromUser(target.serverId);
@@ -227,7 +248,7 @@ export function ScheduleFormSheet({
},
[
clearProviderSelectionFromUser,
selectedProjectTarget,
selectedServerId,
setSelectedServerIdFromUser,
setWorkingDirFromUser,
],
@@ -293,90 +314,127 @@ export function ScheduleFormSheet({
setCadence(schedule?.cadence ?? DEFAULT_CADENCE);
setSubmitError(null);
setFieldResetKey((key) => key + 1);
// The sheet stays mounted, and the form reducer's reset-on-close only
// clears user-modified flags — not the picker values — so a create opened
// after an edit would inherit that schedule's server/cwd (including a
// stale ghost path). Clear them so create always starts fresh; provider
// and model re-resolve from preferences.
if (!isEdit) {
setSelectedServerId(null);
setWorkingDir("");
}
}
wasVisibleRef.current = visible;
}, [visible, schedule]);
}, [visible, schedule, isEdit, setSelectedServerId, setWorkingDir]);
const promptTrimmed = prompt.trim();
const trimmedWorkingDir = workingDir.trim();
const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null;
const selectedModelIsValid = isSelectedModelValidForProviders({
providers: modelSelectorProviders,
selectedProvider,
selectedModel,
});
const canSubmit =
promptTrimmed.length > 0 &&
selectedModelIsValid &&
Boolean(selectedProjectTarget) &&
cadenceError === null &&
!isSubmitting;
const canSubmit = canSubmitScheduleForm({
isAgentTarget,
isEdit,
promptTrimmed,
cadenceError,
isSubmitting,
selectedModelIsValid,
hasWorkingDir: trimmedWorkingDir.length > 0,
hasSelectedProject: Boolean(selectedProjectTarget),
});
const handleSubmit = useCallback(async () => {
if (!selectedProvider || !selectedProjectTarget || !promptTrimmed) {
return;
// Agent target: the update RPC only accepts name/prompt/cadence/maxRuns.
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
if (!schedule) {
return false;
}
setSubmitError(null);
try {
await persistFormPreferences();
const parsedMaxRuns = Number.parseInt(maxRuns, 10);
const maxRunsValue =
Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null;
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
maxRuns: parseMaxRuns(maxRuns),
});
return true;
}, [cadence, maxRuns, name, promptTrimmed, schedule, updateSchedule]);
if (isEdit && schedule) {
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
newAgentConfig: {
provider: selectedProvider,
model: selectedModel || null,
modeId: selectedMode || null,
cwd: selectedProjectTarget.cwd,
},
maxRuns: maxRunsValue,
});
} else {
await createSchedule({
prompt: promptTrimmed,
name: name.trim() || undefined,
cadence,
target: {
type: "new-agent",
config: {
provider: selectedProvider,
cwd: selectedProjectTarget.cwd,
model: selectedModel || undefined,
modeId: selectedMode || undefined,
thinkingOptionId: selectedThinkingOptionId || undefined,
title: name.trim() || undefined,
},
},
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
});
}
onClose();
} catch (error) {
setSubmitError(toErrorMessage(error));
// New-agent target: submit the current working directory. On edit an untouched
// picker leaves this as the stored cwd, so it round-trips unchanged.
const submitNewAgent = useCallback(async (): Promise<boolean> => {
if (!selectedProvider || !trimmedWorkingDir) {
return false;
}
await persistFormPreferences();
const maxRunsValue = parseMaxRuns(maxRuns);
if (isEdit && schedule) {
await updateSchedule({
id: schedule.id,
name: name.trim() || null,
prompt: promptTrimmed,
cadence,
newAgentConfig: {
provider: selectedProvider,
model: selectedModel || null,
modeId: selectedMode || null,
cwd: trimmedWorkingDir,
},
maxRuns: maxRunsValue,
});
return true;
}
await createSchedule({
prompt: promptTrimmed,
name: name.trim() || undefined,
cadence,
target: {
type: "new-agent",
config: {
provider: selectedProvider,
cwd: trimmedWorkingDir,
model: selectedModel || undefined,
modeId: selectedMode || undefined,
thinkingOptionId: selectedThinkingOptionId || undefined,
title: name.trim() || undefined,
},
},
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
});
return true;
}, [
cadence,
createSchedule,
isEdit,
maxRuns,
name,
onClose,
persistFormPreferences,
promptTrimmed,
schedule,
selectedMode,
selectedModel,
selectedProjectTarget,
selectedProvider,
selectedThinkingOptionId,
trimmedWorkingDir,
updateSchedule,
]);
const handleSubmit = useCallback(async () => {
if (!promptTrimmed) {
return;
}
setSubmitError(null);
try {
const submitted = isAgentTarget ? await submitAgentTarget() : await submitNewAgent();
if (submitted) {
onClose();
}
} catch (error) {
setSubmitError(toErrorMessage(error));
}
}, [isAgentTarget, onClose, promptTrimmed, submitAgentTarget, submitNewAgent]);
const handleSubmitPress = useCallback(() => {
void handleSubmit();
}, [handleSubmit]);
@@ -454,34 +512,53 @@ export function ScheduleFormSheet({
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<ProjectField
options={projectOptions.options}
targetByOptionId={projectOptions.targetByOptionId}
value={selectedProjectOptionId}
selectedTarget={selectedProjectTarget}
onSelect={handleSelectProject}
/>
</View>
{isAgentTarget ? (
<View style={styles.field}>
<Text style={styles.label}>Target</Text>
<View style={styles.readonlyField} testID="schedule-agent-target">
<Text style={styles.selectTriggerText} numberOfLines={1}>
{agentTargetLabel}
</Text>
</View>
<Text style={styles.hint}>Runs against this existing agent.</Text>
</View>
) : (
<>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<ProjectField
options={projectOptions.options}
targetByOptionId={projectOptions.targetByOptionId}
value={selectedProjectOptionId}
selectedTarget={selectedProjectTarget}
fallbackCwd={workingDir}
onSelect={handleSelectProject}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Model</Text>
<CombinedModelSelector
providers={modelSelectorProviders}
selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel}
onSelect={setProviderAndModelFromUser}
isLoading={isAllModelsLoading}
renderTrigger={renderModelTrigger}
triggerFill
serverId={mutationServerId}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Model</Text>
<CombinedModelSelector
providers={modelSelectorProviders}
selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel}
onSelect={setProviderAndModelFromUser}
isLoading={isAllModelsLoading}
renderTrigger={renderModelTrigger}
triggerFill
serverId={mutationServerId}
/>
</View>
{modeOptions.length > 0 ? (
<ModeField options={modeOptions} selectedMode={selectedMode} onSelect={setModeFromUser} />
) : null}
{modeOptions.length > 0 ? (
<ModeField
options={modeOptions}
selectedMode={selectedMode}
onSelect={setModeFromUser}
/>
) : null}
</>
)}
<View style={styles.field}>
<Text style={styles.label}>Cadence</Text>
@@ -504,10 +581,6 @@ export function ScheduleFormSheet({
<Text style={styles.hint}>Leave blank to run indefinitely</Text>
</View>
{editConfig === null && isEdit ? (
<Text style={styles.hint}>This schedule does not target a new agent.</Text>
) : null}
{submitError ? <Text style={styles.error}>{submitError}</Text> : null}
</AdaptiveModalSheet>
);
@@ -594,12 +667,15 @@ function ProjectField({
targetByOptionId,
value,
selectedTarget,
fallbackCwd,
onSelect,
}: {
options: ComboboxOption[];
targetByOptionId: Map<string, ScheduleProjectTarget>;
value: string;
selectedTarget: ScheduleProjectTarget | null;
/** Stored cwd for an edited schedule whose path matches no known project. */
fallbackCwd: string;
onSelect: (target: ScheduleProjectTarget) => void;
}): ReactElement {
const anchorRef = useRef<View>(null);
@@ -629,7 +705,13 @@ function ProjectField({
[open],
);
const displayValue = selectedTarget?.projectName ?? "Select project";
// Honest hydration: a stored cwd that matches no known project shows the
// shortened path itself (not the blank "Select project"), and stays put until
// the user deliberately picks a project.
const storedPath = fallbackCwd.trim();
const displayValue =
selectedTarget?.projectName ?? (storedPath ? shortenPath(storedPath) : "Select project");
const isPlaceholder = !selectedTarget && !storedPath;
const description = selectedTarget
? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}`
: null;
@@ -662,7 +744,7 @@ function ProjectField({
testID="schedule-project-trigger"
>
<Text
style={selectedTarget ? styles.selectTriggerText : styles.selectTriggerPlaceholder}
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
numberOfLines={1}
>
{displayValue}
@@ -809,9 +891,20 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
},
error: {
color: theme.colors.destructive,
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
},
readonlyField: {
flexDirection: "row",
alignItems: "center",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
minHeight: 44,
},
selectTrigger: {
flexDirection: "row",
alignItems: "center",
@@ -15,7 +15,9 @@ import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
import { formatTimeAgo } from "@/utils/time";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
@@ -53,43 +55,62 @@ export interface ScheduleRowActions {
interface ScheduleRowProps extends ScheduleRowActions {
schedule: ScheduleSummary;
/** Client-derived target line (agent title / project / shortened path). */
targetLabel: string;
/** Provider glyph, resolved from the schedule config or the target agent. */
provider: string | null;
/** Client-derived state — the single source for the badge and next-run copy. */
state: ScheduleDerivedState;
/** Host name, rendered when the list spans more than one host. */
serverName?: string;
/** True when only one host exists and the host name would be redundant. */
singleHost?: boolean;
pending?: ScheduleRowPending;
isFirst: boolean;
}
function resolveProvider(schedule: ScheduleSummary): string | null {
return schedule.target.type === "new-agent" ? schedule.target.config.provider : null;
function stateBadge(state: ScheduleDerivedState): {
label: string;
variant: "success" | "error" | "muted";
} {
switch (state) {
case "active":
return { label: "Active", variant: "success" };
case "paused":
return { label: "Paused", variant: "muted" };
case "expired":
return { label: "Expired", variant: "muted" };
case "finished":
return { label: "Finished", variant: "muted" };
case "targetGone":
return { label: "Target gone", variant: "error" };
}
}
function resolveModelLabel(schedule: ScheduleSummary): string {
if (schedule.target.type === "new-agent" && schedule.target.config.model) {
return schedule.target.config.model;
// Meta reads left-to-right as identity → history → future: how often, when it
// was created, when it last ran, and (only while it can still run) when it runs
// next. Status lives on the badge, never repeated here.
function buildMeta(
schedule: ScheduleSummary,
state: ScheduleDerivedState,
serverName: string | undefined,
singleHost: boolean,
): string {
const parts = [
formatCadence(schedule.cadence),
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
];
if (state === "active") {
const next = formatNextRun(schedule.nextRunAt);
if (next) {
parts.push(`Next run ${next}`);
}
}
return "Default model";
}
function statusVariant(status: ScheduleSummary["status"]): "success" | "muted" {
return status === "active" ? "success" : "muted";
}
function statusLabel(status: ScheduleSummary["status"]): string {
if (status === "active") {
return "Active";
if (serverName && !singleHost) {
parts.unshift(serverName);
}
if (status === "paused") {
return "Paused";
}
return "Completed";
}
function nextRunLabel(schedule: ScheduleSummary): string {
if (schedule.status === "paused") {
return "Paused";
}
if (schedule.status === "completed") {
return "Completed";
}
return formatNextRun(schedule.nextRunAt) || "—";
return parts.join(" · ");
}
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
@@ -113,6 +134,11 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement
*/
export function ScheduleRow({
schedule,
targetLabel,
provider,
state,
serverName,
singleHost,
pending,
isFirst,
onEdit,
@@ -126,15 +152,10 @@ export function ScheduleRow({
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const provider = resolveProvider(schedule);
const title = resolveScheduleTitle(schedule);
const meta = [
resolveModelLabel(schedule),
formatCadence(schedule.cadence),
nextRunLabel(schedule),
]
.filter(Boolean)
.join(" · ");
const badge = stateBadge(state);
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
const canRun = state === "active" || state === "paused";
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
@@ -168,6 +189,9 @@ export function ScheduleRow({
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.target} numberOfLines={1}>
{targetLabel}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{meta}
</Text>
@@ -175,12 +199,10 @@ export function ScheduleRow({
</View>
<View style={styles.trailing}>
<StatusBadge
label={statusLabel(schedule.status)}
variant={statusVariant(schedule.status)}
/>
<StatusBadge label={badge.label} variant={badge.variant} />
<ScheduleKebabMenu
schedule={schedule}
canRun={canRun}
pending={pending}
onEdit={onEdit}
onPause={onPause}
@@ -211,13 +233,19 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElemen
function ScheduleKebabMenu({
schedule,
canRun,
pending,
onEdit,
onPause,
onResume,
onRunNow,
onDelete,
}: Omit<ScheduleRowProps, "isFirst">): ReactElement {
}: Pick<
ScheduleRowProps,
"schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete"
> & {
canRun: boolean;
}): ReactElement {
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -240,6 +268,7 @@ function ScheduleKebabMenu({
{schedule.status === "paused" ? (
<DropdownMenuItem
leading={resumeLeading}
disabled={!canRun}
status={pending?.resume ? "pending" : "idle"}
pendingLabel="Resuming..."
onSelect={onResume}
@@ -250,7 +279,7 @@ function ScheduleKebabMenu({
) : (
<DropdownMenuItem
leading={pauseLeading}
disabled={schedule.status === "completed"}
disabled={schedule.status === "completed" || !canRun}
status={pending?.pause ? "pending" : "idle"}
pendingLabel="Pausing..."
onSelect={onPause}
@@ -261,6 +290,7 @@ function ScheduleKebabMenu({
)}
<DropdownMenuItem
leading={runLeading}
disabled={!canRun}
status={pending?.runNow ? "pending" : "idle"}
pendingLabel="Starting..."
onSelect={onRunNow}
@@ -324,6 +354,11 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
target: {
marginTop: theme.spacing[1],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
trailing: {
flexDirection: "row",
alignItems: "center",
@@ -3,44 +3,48 @@ import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
import type { AggregatedSchedule } from "@/hooks/use-schedules";
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { resolveScheduleTitle } from "@/utils/schedule-format";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
/** A schedule plus the client-derived fields the row renders. */
export interface ScheduleRowView {
schedule: AggregatedSchedule;
targetLabel: string;
provider: string | null;
state: ScheduleDerivedState;
serverName: string;
/** True when only one host exists, so the host name is redundant in rows. */
singleHost: boolean;
}
interface SchedulesTableProps {
serverId: string;
schedules: ScheduleSummary[];
rows: ScheduleRowView[];
/**
* The form sheet is owned by the screen (it serves both create and edit and
* shares the header's "New schedule" button), so the table delegates edit
* shares the screen's "New schedule" button), so the table delegates edit
* upward rather than mounting a second sheet here.
*/
onEditSchedule: (schedule: ScheduleSummary) => void;
onEditSchedule: (schedule: AggregatedSchedule) => void;
}
/**
* The schedules list: a single settings-style card of rows in a centered,
* width-constrained reading column, matching the projects list. Owns row-level
* actions (pause/resume/run/delete via the mutations hook + a destructive
* confirm for delete) and delegates editing to the parent.
* The schedules list: a single settings-style card of rows across every
* connected host, in a full-width list matching the History screen. Rows own
* their host-scoped mutations (pause/resume/run/delete via the mutations hook +
* a destructive confirm) and delegate editing upward.
*/
export function SchedulesTable({
serverId,
schedules,
onEditSchedule,
}: SchedulesTableProps): ReactElement {
const mutations = useScheduleMutations({ serverId });
export function SchedulesTable({ rows, onEditSchedule }: SchedulesTableProps): ReactElement {
return (
<View style={styles.listContent} testID="schedules-table">
<View style={settingsStyles.card}>
{schedules.map((schedule, index) => (
{rows.map((row, index) => (
<SchedulesTableRow
key={schedule.id}
schedule={schedule}
key={`${row.schedule.serverId}:${row.schedule.id}`}
row={row}
isFirst={index === 0}
mutations={mutations}
onEditSchedule={onEditSchedule}
/>
))}
@@ -50,28 +54,26 @@ export function SchedulesTable({
}
// ---------------------------------------------------------------------------
// Per-row wrapper owns local in-flight state and binds the table's mutation
// callbacks to this schedule. Local state keeps pending precise to the acting
// row even when several rows are acted on at once (the mutations hook exposes
// only a single global pending flag per action).
// Per-row wrapper owns local in-flight state and binds mutations to this
// schedule's host. Local state keeps pending precise to the acting row even
// when several rows are acted on at once (the mutations hook exposes only a
// single global pending flag per action).
// ---------------------------------------------------------------------------
type ScheduleMutations = ReturnType<typeof useScheduleMutations>;
const NO_PENDING: ScheduleRowPending = {};
function SchedulesTableRow({
schedule,
row,
isFirst,
mutations,
onEditSchedule,
}: {
schedule: ScheduleSummary;
row: ScheduleRowView;
isFirst: boolean;
mutations: ScheduleMutations;
onEditSchedule: (schedule: ScheduleSummary) => void;
onEditSchedule: (schedule: AggregatedSchedule) => void;
}): ReactElement {
const { id } = schedule;
const { schedule } = row;
const { id, serverId } = schedule;
const mutations = useScheduleMutations({ serverId });
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
const runAction = useCallback(
@@ -127,6 +129,11 @@ function SchedulesTableRow({
return (
<ScheduleRow
schedule={schedule}
targetLabel={row.targetLabel}
provider={row.provider}
state={row.state}
serverName={row.serverName}
singleHost={row.singleHost}
isFirst={isFirst}
pending={pending}
onEdit={handleEdit}
@@ -138,14 +145,9 @@ function SchedulesTableRow({
);
}
const CONTENT_MAX_WIDTH = 720;
const styles = StyleSheet.create((theme) => ({
// Center the card in a readable column, matching settings and projects.
// Full-width list padding matching the History screen.
listContent: {
width: "100%",
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: "center",
paddingHorizontal: theme.spacing[4],
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
},
}));
@@ -56,6 +56,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { useHostFeatureMap } from "@/runtime/host-features";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import {
@@ -1881,6 +1882,7 @@ function ProjectBlock({
activeWorkspaceSelection,
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
}: {
project: SidebarProjectEntry;
collapsed: boolean;
@@ -1902,14 +1904,16 @@ function ProjectBlock({
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
}) {
const rowModel = useMemo(
() =>
buildSidebarProjectRowModel({
project,
collapsed,
supportsMultiplicityByServerId,
}),
[collapsed, project],
[collapsed, project, supportsMultiplicityByServerId],
);
const active = isProjectSelectedByRoute({
@@ -2062,7 +2066,7 @@ function ProjectBlock({
containerStyle={styles.workspaceListContainer}
/>
);
} else if (rowModel.trailingAction.kind === "new_worktree") {
} else if (rowModel.trailingAction.kind === "new_workspace") {
projectChildren = (
<NewWorkspaceGhostRow
project={project}
@@ -2085,7 +2089,7 @@ function ProjectBlock({
chevron={rowModel.chevron}
onPress={handleToggleCollapsed}
worktreeTarget={
rowModel.trailingAction.kind === "new_worktree" ? rowModel.trailingAction.target : null
rowModel.trailingAction.kind === "new_workspace" ? rowModel.trailingAction.target : null
}
isProjectActive={active}
onWorkspacePress={onWorkspacePress}
@@ -2106,6 +2110,7 @@ function ProjectBlock({
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
// oxlint-disable-next-line complexity
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
return (
previous.project === next.project &&
@@ -2117,6 +2122,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
previous.hostLabelByServerId === next.hostLabelByServerId &&
previous.showHostLabels === next.showHostLabels &&
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
@@ -2183,6 +2189,8 @@ export function SidebarWorkspaceList({
}
return labels;
}, [hosts]);
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
const content =
@@ -2208,6 +2216,7 @@ export function SidebarWorkspaceList({
pathname={pathname}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
/>
);
@@ -2256,6 +2265,7 @@ function ProjectModeList({
pathname,
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
}: Omit<
SidebarWorkspaceListProps,
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
@@ -2263,6 +2273,7 @@ function ProjectModeList({
pathname: string;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
}) {
const { t } = useTranslation();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
@@ -2451,6 +2462,7 @@ function ProjectModeList({
activeWorkspaceSelection={activeWorkspaceSelection}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
/>
);
},
@@ -2461,6 +2473,7 @@ function ProjectModeList({
handleWorkspaceReorder,
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
onWorkspacePress,
onToggleProjectCollapsed,
parentGestureRef,
+1 -1
View File
@@ -50,7 +50,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
},
sm: {
paddingVertical: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
@@ -161,7 +161,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
},
segmentSm: {
paddingVertical: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[4],
},
segmentMd: {
@@ -13,7 +13,10 @@ import type {
} from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
import type { FetchAggregatedSchedulesResult } from "@/schedules/aggregated-schedules";
import type {
AggregatedSchedule,
FetchAggregatedSchedulesResult,
} from "@/schedules/aggregated-schedules";
import { useSessionStore } from "@/stores/session-store";
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
@@ -60,11 +63,9 @@ function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapsh
}
}
function updateScheduleSections(
function updateSchedulesData(
queryClient: QueryClient,
updateSection: (
section: FetchAggregatedSchedulesResult["sections"][number],
) => FetchAggregatedSchedulesResult["sections"][number],
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
): void {
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
{ queryKey: schedulesQueryBaseKey },
@@ -72,7 +73,7 @@ function updateScheduleSections(
if (!current) {
return current;
}
return { sections: current.sections.map(updateSection) };
return { ...current, schedules: updateSchedules(current.schedules) };
},
);
}
@@ -84,26 +85,18 @@ function optimisticallySetStatus(
status: ScheduleSummary["status"],
): void {
const pausedAt = status === "paused" ? new Date().toISOString() : null;
updateScheduleSections(queryClient, (section) =>
section.serverId === serverId
? {
...section,
schedules: section.schedules.map((schedule) =>
schedule.id === id ? { ...schedule, status, pausedAt } : schedule,
),
}
: section,
updateSchedulesData(queryClient, (schedules) =>
schedules.map((schedule) =>
schedule.serverId === serverId && schedule.id === id
? { ...schedule, status, pausedAt }
: schedule,
),
);
}
function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void {
updateScheduleSections(queryClient, (section) =>
section.serverId === serverId
? {
...section,
schedules: section.schedules.filter((schedule) => schedule.id !== id),
}
: section,
updateSchedulesData(queryClient, (schedules) =>
schedules.filter((schedule) => !(schedule.serverId === serverId && schedule.id === id)),
);
}
+20 -15
View File
@@ -1,23 +1,30 @@
import { useMemo, useSyncExternalStore } from "react";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
fetchAggregatedSchedules,
type AggregatedSchedule,
type ScheduleHostError,
type ScheduleHostInput,
type ScheduleHostSection,
} from "@/schedules/aggregated-schedules";
export type { ScheduleHostSection } from "@/schedules/aggregated-schedules";
export type { AggregatedSchedule, ScheduleHostError } from "@/schedules/aggregated-schedules";
export const schedulesQueryBaseKey = ["schedules"] as const;
export function schedulesQueryKey(hosts: readonly ScheduleHostInput[]) {
return [...schedulesQueryBaseKey, hosts.map((host) => host.serverId).join("|")] as const;
// Cache identity for the host set. The query also carries the runtime version
// (below) so it retries as connectivity changes and reliably fetches once a host
// comes online — even on a cold deep-link. The full-screen spinner flash that
// keying on the version used to cause is prevented by keepPreviousData plus the
// isInitialLoad(data === undefined) gate, not by dropping the version.
export function schedulesQueryKey(serverIds: readonly string[]) {
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
}
export interface UseSchedulesResult {
sections: ScheduleHostSection[];
isLoading: boolean;
schedules: AggregatedSchedule[];
hostErrors: ScheduleHostError[];
isInitialLoad: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
@@ -33,23 +40,21 @@ export function useSchedules(): UseSchedulesResult {
() => runtime.getVersion(),
);
const hostInputs = useMemo<ScheduleHostInput[]>(
() =>
hosts.map((host) => ({
serverId: host.serverId,
serverName: host.label,
})),
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
[hosts],
);
const query = useQuery({
queryKey: [...schedulesQueryKey(hostInputs), runtimeVersion] as const,
queryKey: [...schedulesQueryKey(hostInputs.map((host) => host.serverId)), runtimeVersion],
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
staleTime: 5_000,
placeholderData: keepPreviousData,
});
return {
sections: query.data?.sections ?? [],
isLoading: query.isLoading,
schedules: query.data?.schedules ?? [],
hostErrors: query.data?.hostErrors ?? [],
isInitialLoad: query.isLoading && query.data === undefined,
isError: query.isError,
error: query.error,
refetch: () => {
@@ -1,8 +1,9 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { isNewAgentSchedule } from "@/utils/schedule-format";
import { toErrorMessage } from "@/utils/error-messages";
export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules";
export interface ScheduleHostInput {
serverId: string;
serverName: string;
@@ -17,65 +18,76 @@ export interface ScheduleRuntime {
getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined;
}
export interface ScheduleHostSection {
/** A schedule tagged with the host it came from, so the flat list can render a
* per-row host label and scope mutations without host sections. */
export interface AggregatedSchedule extends ScheduleSummary {
serverId: string;
serverName: string;
isOnline: boolean;
schedules: ScheduleSummary[];
error: string | null;
}
export interface FetchAggregatedSchedulesInput {
hosts: ScheduleHostInput[];
runtime: ScheduleRuntime;
export interface ScheduleHostError {
serverId: string;
serverName: string;
message: string;
}
export interface FetchAggregatedSchedulesResult {
sections: ScheduleHostSection[];
schedules: AggregatedSchedule[];
hostErrors: ScheduleHostError[];
}
export interface FetchAggregatedSchedulesInput {
hosts: readonly ScheduleHostInput[];
runtime: ScheduleRuntime;
}
/**
* Fetch schedules across connected hosts and merge them into one flat list.
* Connectivity is checked here at execution time (not pre-filtered by the hook)
* so the query — retried as the runtime version changes — reliably picks a host
* up the moment it comes online, including on a cold deep-link.
*
* Offline hosts are skipped. A connected host that fails contributes to
* `hostErrors` (surfaced as a banner) while the rest still render; only when
* every connected host fails do we throw so the screen shows a full error.
*/
export async function fetchAggregatedSchedules(
input: FetchAggregatedSchedulesInput,
): Promise<FetchAggregatedSchedulesResult> {
const sections = await Promise.all(
input.hosts.map(async (host): Promise<ScheduleHostSection> => {
const schedules: AggregatedSchedule[] = [];
const hostErrors: ScheduleHostError[] = [];
let connectedAttempts = 0;
await Promise.all(
input.hosts.map(async (host) => {
const snapshot = input.runtime.getSnapshot(host.serverId);
const isOnline = snapshot?.connectionStatus === "online";
const client = input.runtime.getClient(host.serverId);
if (!client || !isOnline) {
return {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
schedules: [],
error: null,
};
return;
}
connectedAttempts += 1;
try {
const payload = await client.scheduleList();
if (payload.error) {
throw new Error(payload.error);
}
return {
serverId: host.serverId,
serverName: host.serverName,
isOnline,
schedules: payload.schedules.filter(isNewAgentSchedule),
error: null,
};
for (const schedule of payload.schedules) {
schedules.push({ ...schedule, serverId: host.serverId, serverName: host.serverName });
}
} catch (error) {
return {
hostErrors.push({
serverId: host.serverId,
serverName: host.serverName,
isOnline,
schedules: [],
error: toErrorMessage(error),
};
message: toErrorMessage(error),
});
}
}),
);
return { sections };
if (connectedAttempts > 0 && schedules.length === 0 && hostErrors.length === connectedAttempts) {
throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE);
}
return { schedules, hostErrors };
}
@@ -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" });
});
});
@@ -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),
};
}
@@ -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");
});
});
@@ -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)
);
}
+267 -120
View File
@@ -1,14 +1,40 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import {
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
type ReactElement,
} from "react";
import { ScrollView, Text, View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { Plus } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { MenuHeader } from "@/components/headers/menu-header";
import { HostFilter } from "@/components/hosts/host-filter";
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet";
import { SchedulesTable } from "@/components/schedules/schedules-table";
import { SchedulesTable, type ScheduleRowView } from "@/components/schedules/schedules-table";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useSchedules, type ScheduleHostSection } from "@/hooks/use-schedules";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import {
useSchedules,
type AggregatedSchedule,
type ScheduleHostError,
} from "@/hooks/use-schedules";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
resolveSchedule,
type ScheduleBucket,
type ScheduleTargetAgent,
} from "@/schedules/schedule-derivation";
import {
buildProjectNameByCwd,
buildScheduleProjectTargets,
} from "@/schedules/schedule-project-targets";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
type FormState =
@@ -16,6 +42,11 @@ type FormState =
| { mode: "create" }
| { mode: "edit"; serverId: string; schedule: ScheduleSummary };
const STATUS_FILTER_OPTIONS: { value: ScheduleBucket; label: string; testID: string }[] = [
{ value: "runnable", label: "Active", testID: "schedules-filter-active" },
{ value: "ended", label: "Ended", testID: "schedules-filter-ended" },
];
export function SchedulesScreen(): ReactElement {
const isFocused = useIsFocused();
@@ -27,38 +58,120 @@ export function SchedulesScreen(): ReactElement {
}
function SchedulesScreenContent(): ReactElement {
const { sections, isLoading, isError, error, refetch } = useSchedules();
const [form, setForm] = useState<FormState>({ mode: "closed" });
const openCreate = useCallback(() => {
setForm({ mode: "create" });
}, []);
const openEdit = useCallback((serverId: string, schedule: ScheduleSummary) => {
setForm({ mode: "edit", serverId, schedule });
}, []);
const closeForm = useCallback(() => {
setForm({ mode: "closed" });
}, []);
const headerAction = useMemo(
() => (
<Button leftIcon={Plus} onPress={openCreate} size="sm" testID="schedules-new">
New schedule
</Button>
),
[openCreate],
const { schedules, hostErrors, isInitialLoad, isError, refetch } = useSchedules();
const { agents } = useAggregatedAgents({ includeArchived: true });
const { projects } = useProjects();
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
// Per-host agent-directory readiness from the runtime, not the aggregate agent
// flag: the aggregate `isInitialLoad` flips false as soon as *any* host has
// agents, so a still-loading host would falsely mark its agent-target
// schedules "gone". `hasEverLoadedAgentDirectory` is true only once that
// host's directory has loaded at least once.
const agentDirReadyHosts = useMemo(() => {
void runtimeVersion;
const ready = new Set<string>();
for (const host of hosts) {
if (runtime.getSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) {
ready.add(host.serverId);
}
}
return ready;
}, [hosts, runtime, runtimeVersion]);
const [form, setForm] = useState<FormState>({ mode: "closed" });
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
const [statusFilter, setStatusFilter] = useState<ScheduleBucket>("runnable");
useEffect(() => {
if (
selectedHost !== ALL_HOSTS_OPTION_ID &&
!hosts.some((host) => host.serverId === selectedHost)
) {
setSelectedHost(ALL_HOSTS_OPTION_ID);
}
}, [hosts, selectedHost]);
const openCreate = useCallback(() => setForm({ mode: "create" }), []);
const openEdit = useCallback((schedule: AggregatedSchedule) => {
setForm({ mode: "edit", serverId: schedule.serverId, schedule });
}, []);
const closeForm = useCallback(() => setForm({ mode: "closed" }), []);
const agentsByKey = useMemo(() => {
const map = new Map<string, ScheduleTargetAgent>();
for (const agent of agents) {
map.set(`${agent.serverId}:${agent.id}`, { title: agent.title, provider: agent.provider });
}
return map;
}, [agents]);
const projectNameByCwd = useMemo(
() => buildProjectNameByCwd(buildScheduleProjectTargets(projects)),
[projects],
);
// Resolve every schedule's derived state and target line once, then partition
// by the host and status filters. Sorted newest-first for a stable order
// across hosts.
const resolvedRows = useMemo(() => {
const now = Date.now();
return schedules.map((schedule) => ({
schedule,
resolved: resolveSchedule({
schedule,
serverId: schedule.serverId,
now,
agentsByKey,
projectNameByCwd,
agentDataLoaded: agentDirReadyHosts.has(schedule.serverId),
}),
}));
}, [schedules, agentsByKey, projectNameByCwd, agentDirReadyHosts]);
const visibleRows = useMemo<ScheduleRowView[]>(() => {
const singleHost = hosts.length <= 1;
return resolvedRows
.filter(
({ schedule, resolved }) =>
(selectedHost === ALL_HOSTS_OPTION_ID || schedule.serverId === selectedHost) &&
resolved.bucket === statusFilter,
)
.sort((a, b) => Date.parse(b.schedule.createdAt) - Date.parse(a.schedule.createdAt))
.map(({ schedule, resolved }) => ({
schedule,
targetLabel: resolved.target.label,
provider: resolved.target.provider,
state: resolved.state,
serverName: schedule.serverName,
singleHost,
}));
}, [resolvedRows, selectedHost, statusFilter, hosts.length]);
const showLoadError = isError && schedules.length === 0;
const showHostFilter = hosts.length > 1;
return (
<View style={styles.container}>
<MenuHeader title="Schedules" rightContent={headerAction} />
<SchedulesBody
sections={sections}
isLoading={isLoading}
isError={isError}
error={error}
<MenuHeader title="Schedules" />
<SchedulesScreenBody
rows={visibleRows}
hostErrors={hostErrors}
hasSchedules={schedules.length > 0}
isInitialLoad={isInitialLoad}
showLoadError={showLoadError}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
showHostFilter={showHostFilter}
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
onRetry={refetch}
onCreate={openCreate}
onEdit={openEdit}
@@ -74,24 +187,38 @@ function SchedulesScreenContent(): ReactElement {
);
}
function SchedulesBody({
sections,
isLoading,
isError,
error,
function SchedulesScreenBody({
rows,
hostErrors,
hasSchedules,
isInitialLoad,
showLoadError,
statusFilter,
onStatusFilterChange,
showHostFilter,
hosts,
selectedHost,
onSelectHost,
onRetry,
onCreate,
onEdit,
}: {
sections: ScheduleHostSection[];
isLoading: boolean;
isError: boolean;
error: Error | null;
rows: ScheduleRowView[];
hostErrors: ScheduleHostError[];
hasSchedules: boolean;
isInitialLoad: boolean;
showLoadError: boolean;
statusFilter: ScheduleBucket;
onStatusFilterChange: (value: ScheduleBucket) => void;
showHostFilter: boolean;
hosts: ReturnType<typeof useHosts>;
selectedHost: string;
onSelectHost: (serverId: string) => void;
onRetry: () => void;
onCreate: () => void;
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
onEdit: (schedule: AggregatedSchedule) => void;
}): ReactElement {
if (isLoading) {
if (isInitialLoad) {
return (
<View style={styles.centered}>
<LoadingSpinner size="large" color={styles.spinner.color} />
@@ -99,76 +226,85 @@ function SchedulesBody({
);
}
if (isError) {
if (showLoadError) {
return (
<View style={styles.centered}>
<Text style={styles.message}>{error?.message ?? "Could not load schedules"}</Text>
<Text style={styles.message}>Unable to load schedules</Text>
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
Retry
Try again
</Button>
</View>
);
}
if (sections.length === 0) {
if (!hasSchedules) {
return (
<View style={styles.centered}>
<View style={styles.centered} testID="schedules-empty">
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
<Text style={styles.message}>No schedules yet</Text>
<Button leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
<Button variant="ghost" leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
Create a schedule
</Button>
</View>
);
}
const emptyFilterText = statusFilter === "ended" ? "No ended schedules" : "No active schedules";
return (
<View style={styles.body}>
<View style={styles.filterRow}>
<View style={styles.filterRowControls}>
{showHostFilter ? (
<HostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={onSelectHost}
triggerTestID="schedules-host-filter-trigger"
/>
) : null}
<SegmentedControl
size="sm"
value={statusFilter}
onValueChange={onStatusFilterChange}
options={STATUS_FILTER_OPTIONS}
testID="schedules-status-filter"
/>
</View>
<Button leftIcon={Plus} onPress={onCreate} size="sm" testID="schedules-new">
New schedule
</Button>
</View>
);
}
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
testID="schedules-sections"
>
{sections.map((section) => (
<ScheduleHostSectionView key={section.serverId} section={section} onEdit={onEdit} />
))}
</ScrollView>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
testID="schedules-list"
>
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
{rows.length > 0 ? (
<SchedulesTable rows={rows} onEditSchedule={onEdit} />
) : (
<View style={styles.filterEmpty}>
<Text style={styles.filterEmptyText}>{emptyFilterText}</Text>
</View>
)}
</ScrollView>
</View>
);
}
function 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";
function ScheduleHostErrorsBanner({ errors }: { errors: ScheduleHostError[] }): ReactElement {
return (
<View style={styles.section} testID={`schedules-section-${section.serverId}`}>
<Text style={styles.sectionTitle} testID={`schedules-section-title-${section.serverId}`}>
{section.serverName}
</Text>
{section.error ? <Text style={styles.sectionError}>{section.error}</Text> : null}
{section.schedules.length > 0 ? (
<SchedulesTable
serverId={section.serverId}
schedules={section.schedules}
onEditSchedule={handleEdit}
/>
) : null}
{section.schedules.length === 0 && !section.error ? (
<View style={styles.sectionEmpty}>
<Text style={styles.sectionEmptyText}>{emptyMessage}</Text>
</View>
) : null}
<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>
);
}
@@ -178,6 +314,10 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface0,
},
body: {
flex: 1,
minHeight: 0,
},
centered: {
flex: 1,
justifyContent: "center",
@@ -185,43 +325,50 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[6],
padding: theme.spacing[6],
},
filterRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
paddingTop: theme.spacing[4],
},
filterRowControls: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
flexShrink: 1,
flexWrap: "wrap",
},
scroll: {
flex: 1,
minHeight: 0,
},
scrollContent: {
gap: theme.spacing[4],
gap: theme.spacing[3],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[6],
},
section: {
gap: theme.spacing[2],
errorsBannerWrap: {
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
},
sectionTitle: {
width: "100%",
maxWidth: 720,
alignSelf: "center",
paddingHorizontal: theme.spacing[4],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
errorsBanner: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
gap: theme.spacing[1],
},
sectionError: {
width: "100%",
maxWidth: 720,
alignSelf: "center",
paddingHorizontal: theme.spacing[4],
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
errorsBannerText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
},
sectionEmpty: {
width: "100%",
maxWidth: 720,
alignSelf: "center",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
filterEmpty: {
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
paddingVertical: theme.spacing[6],
alignItems: "center",
},
sectionEmptyText: {
filterEmptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
+7 -102
View File
@@ -1,23 +1,18 @@
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { Pressable, type PressableStateCallbackType, View, Text } from "react-native";
import { useMemo, useState, useCallback, useEffect } from "react";
import { View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown, ChevronLeft, Server } from "lucide-react-native";
import { ChevronLeft } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { AgentList } from "@/components/agent-list";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
HostPicker,
} from "@/components/hosts/host-picker";
import { HostFilter } from "@/components/hosts/host-filter";
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
import { useAgentHistory } from "@/hooks/use-agent-history";
import { useHosts } from "@/runtime/host-runtime";
import { type HostProfile } from "@/types/host-connection";
import { buildOpenProjectRoute } from "@/utils/host-routes";
export function SessionsScreen() {
@@ -30,71 +25,6 @@ export function SessionsScreen() {
return <SessionsScreenContent />;
}
function SessionsHostFilter({
hosts,
selectedHost,
onSelectHost,
}: {
hosts: HostProfile[];
selectedHost: string;
onSelectHost: (serverId: string) => void;
}) {
const { theme } = useUnistyles();
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
const selectedHostLabel = useMemo(
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
[hosts, selectedHost],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
],
[],
);
return (
<HostPicker
hosts={hosts}
value={selectedHost}
onSelect={onSelectHost}
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
includeAllHost
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="sessions-host-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<Server size={14} color={theme.colors.foregroundMuted} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</HostPicker>
);
}
function SessionsScreenContent() {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -152,10 +82,11 @@ function SessionsScreenContent() {
<MenuHeader title={t("sessions.title")} />
{showHostFilter ? (
<View style={styles.filterContainer}>
<SessionsHostFilter
<HostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
triggerTestID="sessions-host-filter-trigger"
/>
</View>
) : null}
@@ -207,32 +138,6 @@ const styles = StyleSheet.create((theme) => ({
},
paddingTop: theme.spacing[4],
},
filterTriggerWrap: {
alignSelf: "flex-start",
},
filterTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
filterTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
},
filterTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
@@ -66,7 +66,7 @@ describe("buildSidebarProjectRowModel", () => {
});
});
it("renders a single-workspace git project as an expandable section with the new worktree action", () => {
it("renders a single-workspace git project as an expandable section with the new workspace action", () => {
const result = buildSidebarProjectRowModel({
project: project({
projectKind: "git",
@@ -79,13 +79,49 @@ describe("buildSidebarProjectRowModel", () => {
kind: "project_section",
chevron: "expand",
trailingAction: {
kind: "new_worktree",
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
},
});
});
it("targets the project host, not route state, for new worktree actions", () => {
it("shows the new workspace action for a non-git project when the host supports workspace multiplicity", () => {
const result = buildSidebarProjectRowModel({
project: project({ projectKind: "directory", workspaces: [] }),
collapsed: false,
supportsMultiplicityByServerId: new Map([["srv", true]]),
});
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
});
});
it("hides the new workspace action for a non-git project when the host lacks workspace multiplicity", () => {
const result = buildSidebarProjectRowModel({
project: project({ projectKind: "directory", workspaces: [] }),
collapsed: false,
supportsMultiplicityByServerId: new Map([["srv", false]]),
});
expect(result.trailingAction).toEqual({ kind: "none" });
});
it("still shows the new workspace action for a git project regardless of multiplicity", () => {
const result = buildSidebarProjectRowModel({
project: project({ projectKind: "git" }),
collapsed: false,
supportsMultiplicityByServerId: new Map([["srv", false]]),
});
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
});
});
it("targets the project host, not route state, for new workspace actions", () => {
const result = buildSidebarProjectRowModel({
project: project({
hosts: [
@@ -98,13 +134,34 @@ describe("buildSidebarProjectRowModel", () => {
expect(result).toMatchObject({
trailingAction: {
kind: "new_worktree",
kind: "new_workspace",
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
},
});
});
it("renders a multi-workspace git project as an expandable section with a new worktree action", () => {
it("targets the first multiplicity-capable host for a non-git project", () => {
const result = buildSidebarProjectRowModel({
project: project({
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false },
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: false },
],
}),
collapsed: false,
supportsMultiplicityByServerId: new Map([["host-b", true]]),
});
expect(result).toMatchObject({
trailingAction: {
kind: "new_workspace",
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
},
});
});
it("renders a multi-workspace git project as an expandable section with a new workspace action", () => {
const result = buildSidebarProjectRowModel({
project: project({
projectKind: "git",
@@ -120,7 +177,7 @@ describe("buildSidebarProjectRowModel", () => {
kind: "project_section",
chevron: "expand",
trailingAction: {
kind: "new_worktree",
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
},
});
@@ -149,7 +206,7 @@ describe("buildSidebarProjectRowModel", () => {
kind: "project_section",
chevron: "collapse",
trailingAction: {
kind: "new_worktree",
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
},
});
@@ -6,7 +6,7 @@ export interface SidebarProjectHostTarget {
}
export type SidebarProjectTrailingAction =
| { kind: "new_worktree"; target: SidebarProjectHostTarget }
| { kind: "new_workspace"; target: SidebarProjectHostTarget }
| { kind: "none" };
export interface SidebarProjectSectionRowModel {
@@ -17,6 +17,8 @@ export interface SidebarProjectSectionRowModel {
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
function hostTarget(input: {
serverId: string;
iconWorkingDir: string;
@@ -40,9 +42,18 @@ export function resolveSidebarProjectIconTarget(
return null;
}
function resolveNewWorktreeTarget(project: SidebarProjectEntry): SidebarProjectHostTarget | null {
// A project can host a brand-new workspace on a host when that host can create a
// git worktree (git projects) OR the host supports running multiple independent
// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git
// directories add a second workspace. Mirrors the gate used by the global "New
// workspace" affordances (use-global-new-workspace-action.ts and left-sidebar's
// SidebarNewWorkspaceHeaderRow): `canCreateWorktree || supportsMultiplicity`.
function resolveNewWorkspaceTarget(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
): SidebarProjectHostTarget | null {
for (const host of project.hosts) {
if (!host.canCreateWorktree) {
if (!host.canCreateWorktree && !supportsMultiplicityByServerId.get(host.serverId)) {
continue;
}
const target = hostTarget(host);
@@ -53,18 +64,25 @@ function resolveNewWorktreeTarget(project: SidebarProjectEntry): SidebarProjectH
return null;
}
function projectTrailingAction(project: SidebarProjectEntry): SidebarProjectTrailingAction {
const target = resolveNewWorktreeTarget(project);
return target ? { kind: "new_worktree", target } : { kind: "none" };
function projectTrailingAction(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
): SidebarProjectTrailingAction {
const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId);
return target ? { kind: "new_workspace", target } : { kind: "none" };
}
export function buildSidebarProjectRowModel(input: {
project: SidebarProjectEntry;
collapsed: boolean;
supportsMultiplicityByServerId?: ReadonlyMap<string, boolean>;
}): SidebarProjectRowModel {
return {
kind: "project_section",
chevron: input.collapsed ? "expand" : "collapse",
trailingAction: projectTrailingAction(input.project),
trailingAction: projectTrailingAction(
input.project,
input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP,
),
};
}
@@ -2803,12 +2803,14 @@ describe("create_schedule MCP tool", () => {
it("requires provider for schedules", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
createStoredSchedule(input),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
scheduleService: { createOrReplace } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
@@ -2820,17 +2822,19 @@ describe("create_schedule MCP tool", () => {
name: "Default schedule",
}),
).rejects.toThrow("provider is required when target is new-agent");
expect(create).not.toHaveBeenCalled();
expect(createOrReplace).not.toHaveBeenCalled();
});
it("keeps create_schedule provider overrides compatible with provider and provider/model forms", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
createStoredSchedule(input),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
scheduleService: { createOrReplace } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
@@ -2846,7 +2850,7 @@ describe("create_schedule MCP tool", () => {
provider: "codex/gpt-5.4",
});
expect(create).toHaveBeenNthCalledWith(
expect(createOrReplace).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
target: {
@@ -2858,7 +2862,7 @@ describe("create_schedule MCP tool", () => {
},
}),
);
expect(create).toHaveBeenNthCalledWith(
expect(createOrReplace).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
target: {
@@ -2888,12 +2892,14 @@ describe("create_schedule MCP tool", () => {
featureValues: { auto_accept: true },
},
} as ManagedAgent);
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
createStoredSchedule(input),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
scheduleService: { createOrReplace } as unknown as ScheduleService,
callerAgentId: "parent-agent",
logger,
});
@@ -2914,14 +2920,14 @@ describe("create_schedule MCP tool", () => {
it("passes timezone through cron create_schedule input", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
const createOrReplace = vi.fn(async (scheduleInput: CreateScheduleInput) =>
createStoredSchedule(scheduleInput),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
scheduleService: { createOrReplace } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
@@ -2933,7 +2939,7 @@ describe("create_schedule MCP tool", () => {
provider: "codex",
});
expect(create).toHaveBeenCalledWith(
expect(createOrReplace).toHaveBeenCalledWith(
expect.objectContaining({
cadence: {
type: "cron",
@@ -2946,12 +2952,12 @@ describe("create_schedule MCP tool", () => {
it("rejects removed create_schedule every input", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const createOrReplace = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
scheduleService: { createOrReplace } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
@@ -2963,17 +2969,17 @@ describe("create_schedule MCP tool", () => {
});
expect(parsed.success).toBe(false);
expect(create).not.toHaveBeenCalled();
expect(createOrReplace).not.toHaveBeenCalled();
});
it("rejects create_schedule without cron", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const createOrReplace = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
scheduleService: { createOrReplace } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
@@ -2985,17 +2991,17 @@ describe("create_schedule MCP tool", () => {
}),
).rejects.toThrow(/cron/);
expect(create).not.toHaveBeenCalled();
expect(createOrReplace).not.toHaveBeenCalled();
});
it.each(["", " "])("rejects create_schedule blank timezone %#", async (timezone) => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const createOrReplace = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
scheduleService: { createOrReplace } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
@@ -3009,7 +3015,7 @@ describe("create_schedule MCP tool", () => {
}),
).rejects.toThrow();
expect(create).not.toHaveBeenCalled();
expect(createOrReplace).not.toHaveBeenCalled();
});
});
@@ -3027,12 +3033,14 @@ describe("create_heartbeat MCP tool", () => {
availableModes: [],
config: { title: "Parent agent" },
} as ManagedAgent);
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
createStoredSchedule(input),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
scheduleService: { createOrReplace } as unknown as ScheduleService,
callerAgentId: "parent-agent",
logger,
});
@@ -3045,7 +3053,7 @@ describe("create_heartbeat MCP tool", () => {
name: "status heartbeat",
});
expect(create).toHaveBeenCalledWith(
expect(createOrReplace).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "check status",
cadence: {
@@ -3061,12 +3069,12 @@ describe("create_heartbeat MCP tool", () => {
it("requires an agent-scoped session", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const createOrReplace = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
scheduleService: { createOrReplace } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_heartbeat");
@@ -3078,7 +3086,7 @@ describe("create_heartbeat MCP tool", () => {
}),
).rejects.toThrow("create_heartbeat requires an agent-scoped session");
expect(create).not.toHaveBeenCalled();
expect(createOrReplace).not.toHaveBeenCalled();
});
});
@@ -2009,7 +2009,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}
const expiresAt = buildScheduleExpiry(expiresIn);
const schedule = await scheduleService.create({
const schedule = await scheduleService.createOrReplace({
prompt: prompt.trim(),
cadence: buildCronScheduleCadence({
cron,
@@ -2058,7 +2058,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
resolveCallerAgent();
const expiresAt = buildScheduleExpiry(expiresIn);
const schedule = await scheduleService.create({
const schedule = await scheduleService.createOrReplace({
prompt: prompt.trim(),
cadence: buildCronScheduleCadence({
cron,
+2 -2
View File
@@ -818,9 +818,9 @@ export async function createPaseoDaemon(
await scheduleService.start();
agentManager.setAgentArchivedCallback(async (agentId) => {
try {
await scheduleService.deleteForAgent(agentId);
await scheduleService.completeForAgent(agentId);
} catch (error) {
logger.warn({ err: error, agentId }, "Failed to delete schedules for archived agent");
logger.warn({ err: error, agentId }, "Failed to complete schedules for archived agent");
}
});
logger.info({ elapsed: elapsed() }, "Schedule service initialized");
@@ -587,8 +587,8 @@ describe("PersistedConfigSchema logging config", () => {
expect(parsed.log?.format).toBe("json");
});
test("rejects unknown logging config fields", () => {
const result = PersistedConfigSchema.safeParse({
test("strips unknown logging config fields", () => {
const parsed = PersistedConfigSchema.parse({
log: {
console: {
level: "info",
@@ -597,7 +597,7 @@ describe("PersistedConfigSchema logging config", () => {
},
});
expect(result.success).toBe(false);
expect(parsed.log?.console).toEqual({ level: "info" });
});
});
@@ -669,6 +669,42 @@ describe("loadPersistedConfig", () => {
}
});
test("loads a config with unrecognized persisted fields", () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
try {
writeFileSync(
configPath,
`${JSON.stringify(
{
version: 1,
daemon: {
listen: "127.0.0.1:6767",
futureDaemonSetting: { enabled: true },
relay: {
enabled: true,
futureRelaySetting: "enabled",
},
},
futureRootSetting: true,
},
null,
2,
)}\n`,
);
const config = loadPersistedConfig(home);
expect(config.daemon?.listen).toBe("127.0.0.1:6767");
expect(config.daemon?.relay?.enabled).toBe(true);
expect((config.daemon as Record<string, unknown>)?.futureDaemonSetting).toBeUndefined();
expect((config.daemon?.relay as Record<string, unknown>)?.futureRelaySetting).toBeUndefined();
expect((config as Record<string, unknown>).futureRootSetting).toBeUndefined();
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("loads a config that still uses the removed providers.openai.voice block", () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
+28 -28
View File
@@ -25,7 +25,7 @@ const LogConfigSchema = z
level: LogLevelSchema.optional(),
format: LogFormatSchema.optional(),
})
.strict()
.strip()
.optional(),
file: z
@@ -37,20 +37,20 @@ const LogConfigSchema = z
maxSize: z.string().min(1).optional(),
maxFiles: z.number().int().positive().optional(),
})
.strict()
.strip()
.optional(),
})
.strict()
.strip()
.optional(),
})
.strict();
.strip();
const OpenAiSpeechEndpointSchema = z
.object({
apiKey: z.string().trim().min(1).optional(),
baseUrl: z.string().trim().min(1).optional(),
})
.strict();
.strip();
const OpenAiProviderSchema = z
.object({
@@ -59,26 +59,26 @@ const OpenAiProviderSchema = z
stt: OpenAiSpeechEndpointSchema.optional(),
tts: OpenAiSpeechEndpointSchema.optional(),
})
.strict();
.strip();
const LocalSpeechProviderSchema = z
.object({
modelsDir: z.string().min(1).optional(),
})
.strict();
.strip();
const ProvidersSchema = z
.object({
openai: OpenAiProviderSchema.optional(),
local: LocalSpeechProviderSchema.optional(),
})
.strict();
.strip();
const WorktreesConfigSchema = z
.object({
root: z.string().min(1).optional(),
})
.strict();
.strip();
const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, {
message: "Expected a bcrypt hash",
@@ -88,7 +88,7 @@ const DaemonAuthSchema = z
.object({
password: BcryptHashSchema.optional(),
})
.strict();
.strip();
const SpeechProviderIdSchema = z
.string()
@@ -106,10 +106,10 @@ const FeatureDictationSchema = z
language: z.string().trim().min(1).optional(),
confidenceThreshold: z.number().optional(),
})
.strict()
.strip()
.optional(),
})
.strict();
.strip();
const FeatureVoiceModeSchema = z
.object({
@@ -119,7 +119,7 @@ const FeatureVoiceModeSchema = z
provider: z.string().optional(),
model: z.string().min(1).optional(),
})
.strict()
.strip()
.optional(),
stt: z
.object({
@@ -127,13 +127,13 @@ const FeatureVoiceModeSchema = z
model: z.string().min(1).optional(),
language: z.string().trim().min(1).optional(),
})
.strict()
.strip()
.optional(),
turnDetection: z
.object({
provider: SpeechProviderIdSchema.optional(),
})
.strict()
.strip()
.optional(),
tts: z
.object({
@@ -143,17 +143,17 @@ const FeatureVoiceModeSchema = z
speakerId: z.number().int().optional(),
speed: z.number().optional(),
})
.strict()
.strip()
.optional(),
})
.strict();
.strip();
const FeatureWebUiSchema = z
.object({
enabled: z.boolean().optional(),
distDir: z.string().min(1).optional(),
})
.strict();
.strip();
const StructuredGenerationProviderConfigSchema = z
.object({
@@ -161,13 +161,13 @@ const StructuredGenerationProviderConfigSchema = z
model: z.string().min(1).optional(),
thinkingOptionId: z.string().min(1).optional(),
})
.strict();
.strip();
const AgentMetadataGenerationSchema = z
.object({
providers: z.array(StructuredGenerationProviderConfigSchema).optional(),
})
.strict();
.strip();
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi", "omp"] as const;
@@ -254,7 +254,7 @@ export const PersistedConfigSchema = z
.object({
allowedOrigins: z.array(z.string()).optional(),
})
.strict()
.strip()
.optional(),
relay: z
.object({
@@ -264,7 +264,7 @@ export const PersistedConfigSchema = z
useTls: z.boolean().optional(),
publicUseTls: z.boolean().optional(),
})
.strict()
.strip()
.optional(),
serviceProxy: z
.object({
@@ -275,11 +275,11 @@ export const PersistedConfigSchema = z
listen: z.string().optional(),
publicBaseUrl: z.url().optional(),
})
.strict()
.strip()
.optional(),
auth: DaemonAuthSchema.optional(),
})
.strict()
.strip()
.transform(({ allowedHosts, ...daemon }) => {
const hostnames = daemon.hostnames ?? allowedHosts;
return hostnames === undefined ? daemon : { ...daemon, hostnames };
@@ -290,7 +290,7 @@ export const PersistedConfigSchema = z
.object({
baseUrl: z.string().optional(),
})
.strict()
.strip()
.optional(),
providers: ProvidersSchema.optional(),
@@ -300,7 +300,7 @@ export const PersistedConfigSchema = z
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
metadataGeneration: AgentMetadataGenerationSchema.optional(),
})
.strict()
.strip()
.optional(),
features: z
.object({
@@ -308,12 +308,12 @@ export const PersistedConfigSchema = z
voiceMode: FeatureVoiceModeSchema.optional(),
webUi: FeatureWebUiSchema.optional(),
})
.strict()
.strip()
.optional(),
log: LogConfigSchema.optional(),
})
.strict();
.strip();
type PersistedConfigSchemaOutput = z.infer<typeof PersistedConfigSchema>;
@@ -22,7 +22,7 @@ import type {
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js";
import { ScheduleService } from "./service.js";
import { ScheduleService, ScheduleTargetGoneError } from "./service.js";
import type { ScheduleExecutionResult, StoredSchedule } from "@getpaseo/protocol/schedule/types";
interface ScheduleServiceInternals {
@@ -45,6 +45,36 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick<ProviderSnapshotManager, "resolveCreat
},
};
function buildAgentRecord(params: {
id: string;
cwd: string;
iso: string;
archivedAt?: string | null;
}) {
return {
id: params.id,
provider: "claude",
cwd: params.cwd,
createdAt: params.iso,
updatedAt: params.iso,
lastActivityAt: params.iso,
lastUserMessageAt: null,
title: params.id,
labels: {},
lastStatus: "closed" as const,
lastModeId: "default",
config: { modeId: "default" },
runtimeInfo: null,
features: [],
persistence: null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: params.archivedAt ?? null,
};
}
describe("ScheduleService", () => {
let tempDir: string;
let agentStorage: AgentStorage;
@@ -1266,7 +1296,7 @@ describe("ScheduleService", () => {
await expect(service.runOnce(created.id)).rejects.toThrow("already completed");
});
test("deleteForAgent removes only schedules targeting that agent", async () => {
test("completeForAgent completes only schedules targeting that agent", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
@@ -1299,12 +1329,577 @@ describe("ScheduleService", () => {
},
});
const deleted = await service.deleteForAgent(targetAgentId);
expect(deleted).toBe(1);
now = new Date("2026-01-01T00:05:00.000Z");
const completed = await service.completeForAgent(targetAgentId);
expect(completed).toBe(1);
const remaining = await service.list();
const remainingIds = remaining.map((schedule) => schedule.id).sort();
expect(remainingIds).toEqual([otherTargeted.id, newAgentSchedule.id].sort());
expect(remainingIds).not.toContain(targeted.id);
expect(remaining.map((schedule) => schedule.id).sort()).toEqual(
[targeted.id, otherTargeted.id, newAgentSchedule.id].sort(),
);
const doomed = await service.inspect(targeted.id);
expect(doomed.status).toBe("completed");
expect(doomed.nextRunAt).toBeNull();
expect(doomed.updatedAt).toBe("2026-01-01T00:05:00.000Z");
expect((await service.inspect(otherTargeted.id)).status).toBe("active");
expect((await service.inspect(newAgentSchedule.id)).status).toBe("active");
});
test("startup sweep completes agent-target schedules whose agent is gone", async () => {
const missingAgentId = "44444444-4444-4444-8444-444444444444";
const archivedAgentId = "55555555-5555-4555-8555-555555555555";
const liveAgentId = "66666666-6666-4666-8666-666666666666";
await agentStorage.upsert(
buildAgentRecord({
id: archivedAgentId,
cwd: tempDir,
iso: now.toISOString(),
archivedAt: "2026-01-01T00:00:30.000Z",
}),
);
await agentStorage.upsert(
buildAgentRecord({ id: liveAgentId, cwd: tempDir, iso: now.toISOString() }),
);
const service1 = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const missing = await service1.create({
prompt: "ping missing",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: missingAgentId },
});
const archived = await service1.create({
prompt: "ping archived",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: archivedAgentId },
});
const live = await service1.create({
prompt: "ping live",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: liveAgentId },
});
const newAgent = await service1.create({
prompt: "spawn fresh",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
});
const pausedMissing = await service1.create({
prompt: "paused ping missing",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1" },
});
await service1.pause(pausedMissing.id);
const pausedLive = await service1.create({
prompt: "paused ping live",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: liveAgentId },
});
await service1.pause(pausedLive.id);
now = new Date("2026-01-01T00:10:00.000Z");
const service2 = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
await service2.start();
await service2.stop();
expect((await service2.inspect(missing.id)).status).toBe("completed");
expect((await service2.inspect(missing.id)).nextRunAt).toBeNull();
expect((await service2.inspect(archived.id)).status).toBe("completed");
expect((await service2.inspect(live.id)).status).toBe("active");
expect((await service2.inspect(newAgent.id)).status).toBe("active");
// Paused schedules are swept too when their agent is gone, but survive when it lives.
expect((await service2.inspect(pausedMissing.id)).status).toBe("completed");
expect((await service2.inspect(pausedLive.id)).status).toBe("paused");
});
test("completes the schedule when a scheduled run reports the target is gone", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => {
throw new ScheduleTargetGoneError("Agent 77777777-7777-4777-8777-777777777777 is archived");
},
});
const created = await service.create({
prompt: "ping gone target",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: "77777777-7777-4777-8777-777777777777" },
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
const inspected = await service.inspect(created.id);
expect(inspected.status).toBe("completed");
expect(inspected.nextRunAt).toBeNull();
expect(inspected.runs).toHaveLength(1);
expect(inspected.runs[0]?.status).toBe("failed");
expect(inspected.runs[0]?.error).toBe("Agent 77777777-7777-4777-8777-777777777777 is archived");
});
test("does not resurrect nextRunAt when the schedule completes during an in-flight run", async () => {
const agentId = "ffffffff-ffff-4fff-8fff-ffffffffffff";
let service!: ScheduleService;
service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => {
// Simulate the agent being archived mid-run: the archive callback
// completes the schedule before this run finishes.
await service.completeForAgent(agentId);
return { agentId: null, output: "ok" };
},
});
const created = await service.create({
prompt: "ping",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId },
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
const inspected = await service.inspect(created.id);
expect(inspected.status).toBe("completed");
expect(inspected.nextRunAt).toBeNull();
expect(inspected.runs[0]?.status).toBe("succeeded");
});
test("keeps the schedule active when a run fails for a transient reason", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => {
throw new Error("network blip");
},
});
const created = await service.create({
prompt: "ping flaky target",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: "88888888-8888-4888-8888-888888888888" },
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
const inspected = await service.inspect(created.id);
expect(inspected.status).toBe("active");
expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z");
expect(inspected.runs[0]?.status).toBe("failed");
expect(inspected.runs[0]?.error).toBe("network blip");
});
test("completes the schedule when a scheduled run targets an archived agent", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
});
const archivedAgentId = "99999999-9999-4999-8999-999999999999";
await agentStorage.upsert(
buildAgentRecord({
id: archivedAgentId,
cwd: tempDir,
iso: now.toISOString(),
archivedAt: "2026-01-01T00:00:30.000Z",
}),
);
const created = await service.create({
prompt: "ping archived target",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: archivedAgentId },
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
const inspected = await service.inspect(created.id);
expect(inspected.status).toBe("completed");
expect(inspected.nextRunAt).toBeNull();
expect(inspected.runs[0]?.status).toBe("failed");
expect(inspected.runs[0]?.error).toContain("is archived");
});
test("completes the schedule when a scheduled run targets a missing agent", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
});
const created = await service.create({
prompt: "ping missing target",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" },
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
const inspected = await service.inspect(created.id);
expect(inspected.status).toBe("completed");
expect(inspected.nextRunAt).toBeNull();
expect(inspected.runs[0]?.status).toBe("failed");
});
test("completes the schedule when a new-agent run's cwd no longer exists", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
});
const created = await service.create({
prompt: "spawn in a deleted dir",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: join(tempDir, "deleted-worktree"),
approvalPolicy: "never",
},
},
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
const inspected = await service.inspect(created.id);
expect(inspected.status).toBe("completed");
expect(inspected.nextRunAt).toBeNull();
expect(inspected.runs[0]?.status).toBe("failed");
expect(inspected.runs[0]?.error).toContain("no longer exists");
});
test("keeps the schedule active when a real run fails for a non-gone reason", async () => {
// No providers registered: the agent exists and is live, but loading it fails
// with a plain error (not ScheduleTargetGoneError), so the schedule must retry.
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
});
const agentId = "12121212-1212-4121-8121-121212121212";
await agentStorage.upsert(
buildAgentRecord({ id: agentId, cwd: tempDir, iso: now.toISOString() }),
);
const created = await service.create({
prompt: "ping live but unavailable",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId },
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
const inspected = await service.inspect(created.id);
expect(inspected.status).toBe("active");
expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z");
expect(inspected.runs[0]?.status).toBe("failed");
expect(inspected.runs[0]?.error).toContain("unavailable provider");
});
test("runOnce completes the schedule when the target is gone", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
});
const created = await service.create({
prompt: "manual ping gone target",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: "13131313-1313-4131-8131-131313131313" },
});
const after = await service.runOnce(created.id);
expect(after.status).toBe("completed");
expect(after.nextRunAt).toBeNull();
expect(after.runs).toHaveLength(1);
expect(after.runs[0]?.status).toBe("failed");
});
test("createOrReplace updates the matching schedule in place instead of duplicating", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const agentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const first = await service.createOrReplace({
name: "babysit-pr PR 1112",
prompt: "watch the build",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId },
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
expect((await service.inspect(first.id)).runs).toHaveLength(1);
await service.pause(first.id);
now = new Date("2026-01-01T00:02:00.000Z");
const second = await service.createOrReplace({
name: "babysit-pr PR 1112",
prompt: "watch the build v2",
cadence: { type: "cron", expression: "30 9 * * *" },
target: { type: "agent", agentId },
});
expect(second.id).toBe(first.id);
expect(second.status).toBe("active");
expect(second.prompt).toBe("watch the build v2");
expect(second.cadence).toEqual({ type: "cron", expression: "30 9 * * *" });
expect(second.nextRunAt).toBe("2026-01-01T09:30:00.000Z");
expect(second.createdAt).toBe(first.createdAt);
expect(second.runs).toHaveLength(1);
expect(await service.list()).toHaveLength(1);
});
test("createOrReplace creates a sibling when name, target, or completion differ", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const agentA = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const agentB = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
await service.createOrReplace({
name: "dup",
prompt: "p",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: agentA },
});
await service.createOrReplace({
name: "other",
prompt: "p",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: agentA },
});
await service.createOrReplace({
name: "dup",
prompt: "p",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: agentB },
});
const done = await service.createOrReplace({
name: "done",
prompt: "p",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: agentA },
maxRuns: 1,
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
expect((await service.inspect(done.id)).status).toBe("completed");
const redone = await service.createOrReplace({
name: "done",
prompt: "p",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId: agentA },
});
expect(redone.id).not.toBe(done.id);
expect(await service.list()).toHaveLength(5);
});
test("createOrReplace never dedups anonymous schedules", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const agentId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
await service.createOrReplace({
prompt: "p",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId },
});
await service.createOrReplace({
prompt: "p",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId },
});
expect(await service.list()).toHaveLength(2);
});
test("createOrReplace matches new-agent targets by config", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const first = await service.createOrReplace({
name: "nightly",
prompt: "audit",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
});
const second = await service.createOrReplace({
name: "nightly",
prompt: "audit v2",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
});
expect(second.id).toBe(first.id);
expect(await service.list()).toHaveLength(1);
const third = await service.createOrReplace({
name: "nightly",
prompt: "audit elsewhere",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "claude", cwd: join(tempDir, "sub") } },
});
expect(third.id).not.toBe(first.id);
expect(await service.list()).toHaveLength(2);
});
test("createOrReplace dedups new-agent targets regardless of config key order", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
// The stored config is round-tripped through the Zod schema (schema key
// order); this incoming literal deliberately uses a different key order.
const first = await service.createOrReplace({
name: "nightly",
prompt: "audit",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: tempDir,
networkAccess: true,
title: "nightly job",
approvalPolicy: "never",
},
},
});
const second = await service.createOrReplace({
name: "nightly",
prompt: "audit v2",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: tempDir,
networkAccess: true,
title: "nightly job",
approvalPolicy: "never",
},
},
});
expect(second.id).toBe(first.id);
expect(await service.list()).toHaveLength(1);
});
test("completeForAgent skips schedules that are already completed", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const agentId = "33333333-3333-4333-8333-333333333333";
await service.create({
prompt: "already done",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "agent", agentId },
maxRuns: 1,
});
now = new Date("2026-01-01T00:01:00.000Z");
expect(await service.completeForAgent(agentId)).toBe(1);
expect(await service.completeForAgent(agentId)).toBe(0);
});
});
+134 -10
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { stat } from "node:fs/promises";
import { join } from "node:path";
import type { Logger } from "pino";
import { AgentManager } from "../agent/agent-manager.js";
@@ -27,6 +28,16 @@ import type {
const SCHEDULE_TICK_INTERVAL_MS = 1000;
// A run failed because its target no longer exists: the agent was deleted or
// archived, or a new-agent cwd was removed. These are permanent, so the schedule
// is completed instead of retried until it burns down to its expiry.
export class ScheduleTargetGoneError extends Error {
constructor(message: string) {
super(message);
this.name = "ScheduleTargetGoneError";
}
}
function trimOptionalName(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -112,6 +123,35 @@ function shouldCompleteSchedule(schedule: StoredSchedule, now: Date): boolean {
return countCompletedRuns(schedule) >= schedule.maxRuns;
}
// Sort object keys recursively so two structurally-equal configs serialize
// identically regardless of key order. Stored configs come back from disk in Zod
// schema order while incoming configs keep their construction order, so a plain
// JSON.stringify comparison would wrongly treat identical targets as different.
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(canonicalize);
}
if (value && typeof value === "object") {
const source = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(source)
.sort()
.map((key) => [key, canonicalize(source[key])]),
);
}
return value;
}
function scheduleTargetsEqual(a: ScheduleTarget, b: ScheduleTarget): boolean {
if (a.type === "agent" && b.type === "agent") {
return a.agentId === b.agentId;
}
if (a.type === "new-agent" && b.type === "new-agent") {
return JSON.stringify(canonicalize(a.config)) === JSON.stringify(canonicalize(b.config));
}
return false;
}
function completeSchedule(schedule: StoredSchedule, now: Date): StoredSchedule {
return {
...schedule,
@@ -177,6 +217,7 @@ export class ScheduleService {
async start(): Promise<void> {
await this.recoverInterruptedRuns();
await this.sweepOrphanedSchedules();
if (this.tickTimer) {
return;
}
@@ -220,6 +261,44 @@ export class ScheduleService {
return schedule;
}
// Idempotent create for the MCP write path: repeating a create with the same
// name and target (e.g. babysit-pr re-registering its heartbeat) refreshes the
// existing non-completed schedule in place instead of minting a duplicate.
async createOrReplace(input: CreateScheduleInput): Promise<StoredSchedule> {
const name = trimOptionalName(input.name);
if (name !== null) {
const existing = (await this.store.list()).find(
(schedule) =>
schedule.status !== "completed" &&
trimOptionalName(schedule.name) === name &&
scheduleTargetsEqual(schedule.target, input.target),
);
if (existing) {
const now = this.now();
const prompt = normalizePrompt(input.prompt);
validateScheduleCadence(input.cadence);
const runOnCreate = input.runOnCreate ?? input.cadence.type === "every";
const nextRunAt = runOnCreate ? now : computeNextRunAt(input.cadence, now);
const replaced: StoredSchedule = {
...existing,
name,
prompt,
cadence: input.cadence,
target: input.target,
status: "active",
pausedAt: null,
nextRunAt: nextRunAt.toISOString(),
expiresAt: input.expiresAt ?? null,
maxRuns: normalizeMaxRuns(input.maxRuns),
updatedAt: now.toISOString(),
};
await this.store.put(replaced);
return replaced;
}
}
return this.create(input);
}
async list(): Promise<StoredSchedule[]> {
return this.store.list();
}
@@ -321,26 +400,30 @@ export class ScheduleService {
await this.store.delete(id);
}
async deleteForAgent(agentId: string): Promise<number> {
async completeForAgent(agentId: string): Promise<number> {
const now = this.now();
const schedules = await this.store.list();
const matches = schedules.filter(
(schedule) => schedule.target.type === "agent" && schedule.target.agentId === agentId,
(schedule) =>
schedule.target.type === "agent" &&
schedule.target.agentId === agentId &&
schedule.status !== "completed",
);
const results = await Promise.allSettled(
matches.map((schedule) => this.store.delete(schedule.id)),
matches.map((schedule) => this.store.put(completeSchedule(schedule, now))),
);
let deleted = 0;
let completed = 0;
for (const [index, result] of results.entries()) {
if (result.status === "fulfilled") {
deleted += 1;
completed += 1;
} else {
this.logger.warn(
{ err: result.reason, scheduleId: matches[index].id, agentId },
"Failed to delete schedule for archived agent; continuing",
"Failed to complete schedule for archived agent; continuing",
);
}
}
return deleted;
return completed;
}
async runOnce(id: string): Promise<StoredSchedule> {
@@ -420,6 +503,26 @@ export class ScheduleService {
);
}
// Orphaned agent-target schedules (agent deleted while the daemon was down, or
// archived before completeForAgent existed) can never fire successfully. Complete
// them on startup so they stop ticking and surface as ended in the UI.
private async sweepOrphanedSchedules(): Promise<void> {
const now = this.now();
const schedules = await this.store.list();
await Promise.all(
schedules.map(async (schedule) => {
if (schedule.target.type !== "agent" || schedule.status === "completed") {
return;
}
const record = await this.agentStorage.get(schedule.target.agentId);
if (record && !record.archivedAt) {
return;
}
await this.store.put(completeSchedule(schedule, now));
}),
);
}
private async runSchedule(
schedule: StoredSchedule,
now: Date,
@@ -454,6 +557,7 @@ export class ScheduleService {
agentId: result.agentId,
output: result.output,
error: null,
targetGone: false,
manual,
});
} catch (error) {
@@ -464,6 +568,7 @@ export class ScheduleService {
agentId: null,
output: null,
error: error instanceof Error ? error.message : String(error),
targetGone: error instanceof ScheduleTargetGoneError,
manual,
});
} finally {
@@ -478,6 +583,7 @@ export class ScheduleService {
agentId: string | null;
output: string | null;
error: string | null;
targetGone: boolean;
manual: boolean;
}): Promise<void> {
const schedule = await this.inspect(params.scheduleId);
@@ -501,7 +607,14 @@ export class ScheduleService {
updatedAt: now.toISOString(),
};
if (params.manual) {
if (params.targetGone) {
// The target is permanently gone; retrying only burns the schedule down to
// its expiry, so complete it now regardless of manual/scheduled origin.
updated = completeSchedule(updated, now);
} else if (updated.status === "completed") {
// Completed concurrently (e.g. the target agent was archived mid-run);
// record the run outcome but leave the schedule terminal — don't advance.
} else if (params.manual) {
// Manual one-shot runs do not advance the cadence or recompute completion.
} else if (shouldCompleteSchedule(updated, now)) {
updated = completeSchedule(updated, now);
@@ -532,8 +645,11 @@ export class ScheduleService {
if (schedule.target.type === "agent") {
const wrappedPrompt = formatSystemNotificationPrompt(buildScheduleFireBody(schedule, runId));
const record = await this.agentStorage.get(schedule.target.agentId);
if (record?.archivedAt) {
throw new Error(`Agent ${schedule.target.agentId} is archived`);
if (!record) {
throw new ScheduleTargetGoneError(`Agent ${schedule.target.agentId} no longer exists`);
}
if (record.archivedAt) {
throw new ScheduleTargetGoneError(`Agent ${schedule.target.agentId} is archived`);
}
const agent = await ensureAgentLoaded(schedule.target.agentId, {
@@ -557,6 +673,14 @@ export class ScheduleService {
}
const targetConfig = schedule.target.config;
try {
await stat(targetConfig.cwd);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new ScheduleTargetGoneError(`Working directory ${targetConfig.cwd} no longer exists`);
}
throw error;
}
const resolvedUnattendedConfig = targetConfig.modeId
? { modeId: targetConfig.modeId, featureValues: targetConfig.featureValues }
: await this.resolveProviderCreateConfig({