Make workspace, agent, and schedule automation consistent (#2186)
* feat(workspaces): align agent and schedule automation Make workspace identity the shared placement boundary for MCP and CLI, while caller identity determines parentage. Keep heartbeats minimal and cron-based without changing legacy rolling interval semantics. * test(cli): expect canonical schedule cadence * fix(automation): preserve workspace and schedule compatibility * fix(automation): preserve compatibility edges * fix(workspaces): align MCP lifecycle resolution * fix(workspaces): preserve creation intent * fix(workspaces): preserve branch and schedule identity
This commit is contained in:
@@ -13,7 +13,7 @@ import { Text, View } from "react-native";
|
||||
import { Brain, Folder, GitBranch } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { ComboboxItem } from "@/components/ui/combobox";
|
||||
@@ -71,6 +71,15 @@ function parseMaxRuns(raw: string): number | null {
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function requireCronCadence(
|
||||
cadence: Extract<ScheduleCadence, { type: "cron" }> | undefined,
|
||||
): Extract<ScheduleCadence, { type: "cron" }> {
|
||||
if (!cadence) {
|
||||
throw new Error("Choose a cron cadence before creating this schedule");
|
||||
}
|
||||
return cadence;
|
||||
}
|
||||
|
||||
function resolveCreateServerId(input: {
|
||||
mode: "create" | "edit";
|
||||
serverId: string | null | undefined;
|
||||
@@ -306,18 +315,15 @@ function OpenScheduleFormSheet({
|
||||
]);
|
||||
|
||||
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
|
||||
if (!schedule) {
|
||||
if (!schedule || !state.submitCadence) {
|
||||
return false;
|
||||
}
|
||||
await updateSchedule({
|
||||
id: schedule.id,
|
||||
name: state.name.trim() || null,
|
||||
prompt: state.prompt.trim(),
|
||||
cadence: state.submitCadence,
|
||||
maxRuns: parseMaxRuns(state.maxRuns),
|
||||
});
|
||||
return true;
|
||||
}, [schedule, state.maxRuns, state.name, state.prompt, state.submitCadence, updateSchedule]);
|
||||
}, [schedule, state.submitCadence, updateSchedule]);
|
||||
|
||||
const submitNewAgent = useCallback(async (): Promise<boolean> => {
|
||||
const provider = state.selectedProvider;
|
||||
@@ -333,7 +339,7 @@ function OpenScheduleFormSheet({
|
||||
id: schedule.id,
|
||||
name: state.name.trim() || null,
|
||||
prompt: state.prompt.trim(),
|
||||
cadence: state.submitCadence,
|
||||
...(state.submitCadence ? { cadence: state.submitCadence } : {}),
|
||||
newAgentConfig: {
|
||||
provider,
|
||||
model: state.selectedModel || null,
|
||||
@@ -353,7 +359,7 @@ function OpenScheduleFormSheet({
|
||||
await createSchedule({
|
||||
prompt: state.prompt.trim(),
|
||||
name: state.name.trim() || undefined,
|
||||
cadence: state.submitCadence,
|
||||
cadence: requireCronCadence(state.submitCadence),
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
@@ -394,10 +400,12 @@ function OpenScheduleFormSheet({
|
||||
void handleSubmit();
|
||||
}, [handleSubmit]);
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: mode === "edit" ? "Edit schedule" : "New schedule" }),
|
||||
[mode],
|
||||
);
|
||||
const header = useMemo<SheetHeader>(() => {
|
||||
if (mode !== "edit") {
|
||||
return { title: "New schedule" };
|
||||
}
|
||||
return { title: schedule?.target.type === "agent" ? "Edit heartbeat" : "Edit schedule" };
|
||||
}, [mode, schedule?.target.type]);
|
||||
|
||||
const footer = useMemo(
|
||||
() => (
|
||||
@@ -466,6 +474,21 @@ function ScheduleFormFields({
|
||||
cadenceError,
|
||||
mutationServerId,
|
||||
}: ScheduleFormFieldsProps): ReactElement {
|
||||
if (state.targetKind === "agent") {
|
||||
return (
|
||||
<>
|
||||
<ScheduleAgentTargetField label={agentTargetLabel} size={controlSize} />
|
||||
<CadenceEditor
|
||||
value={state.cadence}
|
||||
onChange={model.setCadence}
|
||||
error={cadenceError ?? undefined}
|
||||
size={controlSize}
|
||||
/>
|
||||
{state.submitError ? <Text style={styles.submitError}>{state.submitError}</Text> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label="Name">
|
||||
@@ -502,7 +525,7 @@ function ScheduleFormFields({
|
||||
model={model}
|
||||
state={state}
|
||||
providerSnapshot={providerSnapshot}
|
||||
agentTargetLabel={agentTargetLabel}
|
||||
agentTargetLabel={null}
|
||||
controlSize={controlSize}
|
||||
mutationServerId={mutationServerId}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,12 @@ 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 {
|
||||
formatCadence,
|
||||
formatNextRun,
|
||||
resolveScheduleTitle,
|
||||
scheduleProductName,
|
||||
} from "@/utils/schedule-format";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
@@ -153,9 +158,10 @@ export function ScheduleRow({
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
|
||||
const title = resolveScheduleTitle(schedule);
|
||||
const productName = scheduleProductName(schedule);
|
||||
const badge = stateBadge(state);
|
||||
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
|
||||
const canRun = state === "active" || state === "paused";
|
||||
const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused");
|
||||
|
||||
const rowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
@@ -178,7 +184,7 @@ export function ScheduleRow({
|
||||
style={rowStyle}
|
||||
onPress={onEdit}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Edit schedule ${title}`}
|
||||
accessibilityLabel={`Edit ${productName.toLowerCase()} ${title}`}
|
||||
testID={`schedule-row-${schedule.id}`}
|
||||
>
|
||||
<View style={styles.main}>
|
||||
@@ -222,6 +228,66 @@ const resumeLeading = <ThemedPlay size={MENU_ICON_SIZE} uniProps={mutedColorMapp
|
||||
const runLeading = <ThemedRotateCw size={MENU_ICON_SIZE} uniProps={mutedColorMapping} />;
|
||||
const deleteLeading = <ThemedTrash2 size={MENU_ICON_SIZE} uniProps={destructiveColorMapping} />;
|
||||
|
||||
function ScheduleExecutionMenuItems({
|
||||
schedule,
|
||||
canRun,
|
||||
pending,
|
||||
onPause,
|
||||
onResume,
|
||||
onRunNow,
|
||||
}: Pick<ScheduleRowProps, "schedule" | "pending" | "onPause" | "onResume" | "onRunNow"> & {
|
||||
canRun: boolean;
|
||||
}): ReactElement | null {
|
||||
if (schedule.target.type === "agent") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cadenceAction: ReactElement;
|
||||
if (schedule.status === "paused") {
|
||||
cadenceAction = (
|
||||
<DropdownMenuItem
|
||||
leading={resumeLeading}
|
||||
disabled={!canRun}
|
||||
status={pending?.resume ? "pending" : "idle"}
|
||||
pendingLabel="Resuming..."
|
||||
onSelect={onResume}
|
||||
testID={`schedule-menu-resume-${schedule.id}`}
|
||||
>
|
||||
Resume schedule
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
} else {
|
||||
cadenceAction = (
|
||||
<DropdownMenuItem
|
||||
leading={pauseLeading}
|
||||
disabled={schedule.status === "completed" || !canRun}
|
||||
status={pending?.pause ? "pending" : "idle"}
|
||||
pendingLabel="Pausing..."
|
||||
onSelect={onPause}
|
||||
testID={`schedule-menu-pause-${schedule.id}`}
|
||||
>
|
||||
Pause schedule
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{cadenceAction}
|
||||
<DropdownMenuItem
|
||||
leading={runLeading}
|
||||
disabled={!canRun}
|
||||
status={pending?.runNow ? "pending" : "idle"}
|
||||
pendingLabel="Starting..."
|
||||
onSelect={onRunNow}
|
||||
testID={`schedule-menu-run-${schedule.id}`}
|
||||
>
|
||||
Run now
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement {
|
||||
return (
|
||||
<ThemedKebab
|
||||
@@ -246,13 +312,15 @@ function ScheduleKebabMenu({
|
||||
> & {
|
||||
canRun: boolean;
|
||||
}): ReactElement {
|
||||
const productName = scheduleProductName(schedule);
|
||||
const productNameLower = productName.toLowerCase();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={kebabTriggerStyle}
|
||||
accessibilityRole={isNative ? "button" : undefined}
|
||||
accessibilityLabel="Schedule actions"
|
||||
accessibilityLabel={`${productName} actions`}
|
||||
testID={`schedule-kebab-${schedule.id}`}
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
@@ -263,41 +331,16 @@ function ScheduleKebabMenu({
|
||||
onSelect={onEdit}
|
||||
testID={`schedule-menu-edit-${schedule.id}`}
|
||||
>
|
||||
Edit schedule
|
||||
</DropdownMenuItem>
|
||||
{schedule.status === "paused" ? (
|
||||
<DropdownMenuItem
|
||||
leading={resumeLeading}
|
||||
disabled={!canRun}
|
||||
status={pending?.resume ? "pending" : "idle"}
|
||||
pendingLabel="Resuming..."
|
||||
onSelect={onResume}
|
||||
testID={`schedule-menu-resume-${schedule.id}`}
|
||||
>
|
||||
Resume schedule
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
leading={pauseLeading}
|
||||
disabled={schedule.status === "completed" || !canRun}
|
||||
status={pending?.pause ? "pending" : "idle"}
|
||||
pendingLabel="Pausing..."
|
||||
onSelect={onPause}
|
||||
testID={`schedule-menu-pause-${schedule.id}`}
|
||||
>
|
||||
Pause schedule
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
leading={runLeading}
|
||||
disabled={!canRun}
|
||||
status={pending?.runNow ? "pending" : "idle"}
|
||||
pendingLabel="Starting..."
|
||||
onSelect={onRunNow}
|
||||
testID={`schedule-menu-run-${schedule.id}`}
|
||||
>
|
||||
Run now
|
||||
Edit {productNameLower}
|
||||
</DropdownMenuItem>
|
||||
<ScheduleExecutionMenuItems
|
||||
schedule={schedule}
|
||||
canRun={canRun}
|
||||
pending={pending}
|
||||
onPause={onPause}
|
||||
onResume={onResume}
|
||||
onRunNow={onRunNow}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
leading={deleteLeading}
|
||||
@@ -307,7 +350,7 @@ function ScheduleKebabMenu({
|
||||
onSelect={onDelete}
|
||||
testID={`schedule-menu-delete-${schedule.id}`}
|
||||
>
|
||||
Delete schedule
|
||||
Delete {productNameLower}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { resolveScheduleTitle, scheduleProductName } from "@/utils/schedule-format";
|
||||
|
||||
/** A schedule plus the client-derived fields the row renders. */
|
||||
export interface ScheduleRowView {
|
||||
@@ -113,8 +113,9 @@ function SchedulesTableRow({
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
void (async () => {
|
||||
const productName = scheduleProductName(schedule);
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Delete schedule",
|
||||
title: `Delete ${productName.toLowerCase()}`,
|
||||
message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`,
|
||||
confirmLabel: "Delete",
|
||||
destructive: true,
|
||||
|
||||
@@ -113,6 +113,26 @@ function scheduleOnHost(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function heartbeatOnHost(cadence: ScheduleSummary["cadence"]): TestSchedule {
|
||||
return {
|
||||
id: "heartbeat-host-a",
|
||||
serverId: "host-a",
|
||||
serverName: "Host A",
|
||||
name: "Babysit",
|
||||
prompt: "Check status",
|
||||
cadence,
|
||||
target: { type: "agent", agentId: "agent-1" },
|
||||
status: "active",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
nextRunAt: "2026-07-02T00:00:00.000Z",
|
||||
lastRunAt: null,
|
||||
pausedAt: null,
|
||||
expiresAt: null,
|
||||
maxRuns: null,
|
||||
};
|
||||
}
|
||||
|
||||
function providerSnapshot(models: AgentModelDefinition[]): { entries: ProviderSnapshotEntry[] } {
|
||||
return {
|
||||
entries: [
|
||||
@@ -389,7 +409,7 @@ describe("schedule form model", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes interval cadences to cron cadences when opening the form", () => {
|
||||
it("displays a representable legacy interval without submitting a cadence change", () => {
|
||||
const form = open({
|
||||
mode: "edit",
|
||||
schedule: scheduleOnHost({
|
||||
@@ -412,9 +432,12 @@ describe("schedule form model", () => {
|
||||
expression: "* * * * *",
|
||||
timezone: "Europe/Madrid",
|
||||
});
|
||||
form.setName("Renamed without touching cadence");
|
||||
|
||||
expect(form.getState().submitCadence).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves an edited schedule's interval cadence until the cadence changes", () => {
|
||||
it("does not rewrite an unrepresentable legacy interval until cadence changes", () => {
|
||||
const originalCadence = { type: "every" as const, everyMs: 90 * 60_000 };
|
||||
const form = open({
|
||||
mode: "edit",
|
||||
@@ -441,7 +464,7 @@ describe("schedule form model", () => {
|
||||
|
||||
form.setName("Renamed without touching cadence");
|
||||
|
||||
expect(form.getState().submitCadence).toEqual(originalCadence);
|
||||
expect(form.getState().submitCadence).toBeUndefined();
|
||||
|
||||
form.setCadence({
|
||||
type: "cron",
|
||||
@@ -456,6 +479,28 @@ describe("schedule form model", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a cron choice before updating a legacy heartbeat", () => {
|
||||
const form = open({
|
||||
mode: "edit",
|
||||
schedule: heartbeatOnHost({ type: "every", everyMs: 90 * 60_000 }),
|
||||
defaults: {
|
||||
serverId: null,
|
||||
projectTargets: PROJECT_TARGETS,
|
||||
preferences: {},
|
||||
timezone: "Europe/Madrid",
|
||||
},
|
||||
});
|
||||
|
||||
expect(form.getState()).toMatchObject({ targetKind: "agent", canSubmit: false });
|
||||
|
||||
form.setCadence({ type: "cron", expression: "0 9 * * *", timezone: "Europe/Madrid" });
|
||||
|
||||
expect(form.getState()).toMatchObject({
|
||||
submitCadence: { type: "cron", expression: "0 9 * * *", timezone: "Europe/Madrid" },
|
||||
canSubmit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears provider selection while resolving a different project", () => {
|
||||
const form = open({
|
||||
mode: "create",
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface ScheduleFormProjectOption {
|
||||
}
|
||||
|
||||
export type ScheduleFormTargetKind = "agent" | "new-agent";
|
||||
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
|
||||
type ProviderResolutionStatus = "idle" | "pending" | "complete";
|
||||
|
||||
export interface ScheduleFormState {
|
||||
@@ -86,7 +87,7 @@ export interface ScheduleFormState {
|
||||
prompt: string;
|
||||
maxRuns: string;
|
||||
cadence: ScheduleCadence;
|
||||
submitCadence: ScheduleCadence;
|
||||
submitCadence: CronCadence | undefined;
|
||||
hosts: ScheduleFormHost[];
|
||||
projectOptions: ScheduleFormProjectOption[];
|
||||
selectedServerId: string | null;
|
||||
@@ -436,13 +437,10 @@ function formatInitialMaxRuns(schedule: ScheduleFormSnapshot["schedule"]): strin
|
||||
}
|
||||
|
||||
function resolveInitialSubmitCadence(
|
||||
snapshot: ScheduleFormSnapshot,
|
||||
initialCadence: ScheduleCadence,
|
||||
): ScheduleCadence {
|
||||
if (snapshot.mode === "edit" && snapshot.schedule) {
|
||||
return snapshot.schedule.cadence;
|
||||
}
|
||||
return initialCadence;
|
||||
schedule: ScheduleFormSnapshot["schedule"],
|
||||
initialCadence: CronCadence,
|
||||
): CronCadence | undefined {
|
||||
return schedule ? undefined : initialCadence;
|
||||
}
|
||||
|
||||
function resolveInitialIsolation(input: {
|
||||
@@ -547,12 +545,12 @@ function resolveDisclosure(state: ScheduleFormState): ScheduleDisclosureState {
|
||||
}
|
||||
|
||||
function resolveCanSubmit(state: ScheduleFormState): boolean {
|
||||
if (state.targetKind === "agent") {
|
||||
return state.submitCadence !== undefined;
|
||||
}
|
||||
if (state.prompt.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (state.targetKind === "agent") {
|
||||
return true;
|
||||
}
|
||||
const hasWorkingDir = state.workingDir.trim().length > 0;
|
||||
const hasMatchedProject = state.selectedProjectOptionId.trim().length > 0;
|
||||
if (state.mode === "create" && !hasMatchedProject) {
|
||||
@@ -657,7 +655,7 @@ function buildInitialState(snapshot: ScheduleFormSnapshot): ScheduleFormState {
|
||||
prompt: snapshot.schedule?.prompt ?? "",
|
||||
maxRuns: formatInitialMaxRuns(snapshot.schedule),
|
||||
cadence: initialCadence,
|
||||
submitCadence: resolveInitialSubmitCadence(snapshot, initialCadence),
|
||||
submitCadence: resolveInitialSubmitCadence(snapshot.schedule, initialCadence),
|
||||
hosts: [...snapshot.hosts],
|
||||
projectOptions: buildProjectOptions(snapshot.defaults.projectTargets, selectedServerId),
|
||||
selectedServerId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
formatCadence,
|
||||
formatNextRun,
|
||||
isNewAgentSchedule,
|
||||
scheduleProductName,
|
||||
partsToEveryMs,
|
||||
resolveScheduleTitle,
|
||||
validateCron,
|
||||
@@ -54,6 +55,11 @@ describe("schedule title helpers", () => {
|
||||
expect(isNewAgentSchedule(createSchedule({ targetType: "agent" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("labels engine records by product meaning", () => {
|
||||
expect(scheduleProductName(createSchedule({ targetType: "new-agent" }))).toBe("Schedule");
|
||||
expect(scheduleProductName(createSchedule({ targetType: "agent" }))).toBe("Heartbeat");
|
||||
});
|
||||
|
||||
it("resolves display titles by name, config title, prompt, then fallback", () => {
|
||||
expect(
|
||||
resolveScheduleTitle(createSchedule({ name: "Morning run", title: "Config title" })),
|
||||
|
||||
@@ -28,6 +28,10 @@ export function isNewAgentSchedule(schedule: ScheduleSummary): boolean {
|
||||
return schedule.target.type === "new-agent";
|
||||
}
|
||||
|
||||
export function scheduleProductName(schedule: ScheduleSummary): "Heartbeat" | "Schedule" {
|
||||
return schedule.target.type === "agent" ? "Heartbeat" : "Schedule";
|
||||
}
|
||||
|
||||
export function resolveScheduleTitle(schedule: ScheduleSummary): string {
|
||||
const name = schedule.name?.trim();
|
||||
if (name) {
|
||||
@@ -43,7 +47,7 @@ export function resolveScheduleTitle(schedule: ScheduleSummary): string {
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
return firstPromptLine || "Untitled schedule";
|
||||
return firstPromptLine || `Untitled ${scheduleProductName(schedule).toLowerCase()}`;
|
||||
}
|
||||
|
||||
function pluralize(value: number, noun: string): string {
|
||||
|
||||
Reference in New Issue
Block a user