Scheduled and loop agents get workspaces; schedules UI speaks the user's timezone (#1909)
* fix(app): schedules UI speaks the user's timezone * refactor(server): require explicit agent placement and bind create-agent command * fix(server): scheduled agents get a workspace and show in the sidebar * fix(server): loop agents get a workspace and show in the sidebar * fix(server): preserve scheduled and loop run semantics through the create command * fix(server): serialize all schedule record writes through the per-schedule queue * fix(server): tighten create-agent dependency contracts * fix(server): handle schedule restamps and loop cancellations * fix(server): preserve scheduled and loop create semantics * refactor(server): schedule store owns atomic mutations * fix(server): keep schedule stamp reuse deterministic * fix(server): handle loop workspace stamp rejection * refactor(server): schedule store owns typed identity upsert * fix(server): keep schedule workspace stamps server-owned * fix(server): preserve scheduled run semantics * refactor: required nullable workspaceId replaces placement union; pure cadence policy * fix(server): tighten scheduled and loop agent edges * fix(server): fail canceled scheduled runs * fix(server): close schedule and loop cwd edges * fix(server): refresh schedule workspace stamps * fix(server): preserve schedule timezone on replace * fix(server): close loop stop and cadence reset edges
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
import { type ReactNode, useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
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 { getDeviceTimeZone } from "@/utils/device-timezone";
|
||||
import { nextCronCadence } from "@/utils/schedule-cadence-policy";
|
||||
import {
|
||||
describeCron,
|
||||
everyMsToParts,
|
||||
@@ -20,8 +30,8 @@ interface CronPreset {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
// 5-field expressions evaluated in UTC by the daemon. Each one round-trips
|
||||
// through describeCron() so the chip and the live preview agree.
|
||||
// 5-field expressions use the cadence timezone. Each one round-trips through
|
||||
// describeCron() so the chip and the live preview agree.
|
||||
const CRON_PRESETS: CronPreset[] = [
|
||||
{ label: "Every hour", expression: "0 * * * *" },
|
||||
{ label: "Daily 9:00", expression: "0 9 * * *" },
|
||||
@@ -57,6 +67,25 @@ function describeInterval(value: number, unit: IntervalUnit): string {
|
||||
return `Runs every ${value} ${noun}s`;
|
||||
}
|
||||
|
||||
function getCronPreview(expression: string, timezone: string, error: string | null): string | null {
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
if (!expression) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const described = describeCron({ type: "cron", expression, timezone });
|
||||
if (described) {
|
||||
return described;
|
||||
}
|
||||
return expression;
|
||||
}
|
||||
|
||||
function intervalCadenceKey(cadence: Extract<ScheduleCadence, { type: "every" }>): string {
|
||||
return `${cadence.type}:${cadence.everyMs}`;
|
||||
}
|
||||
|
||||
export interface CadenceEditorProps {
|
||||
value: ScheduleCadence;
|
||||
onChange: (next: ScheduleCadence) => void;
|
||||
@@ -65,6 +94,13 @@ export interface CadenceEditorProps {
|
||||
|
||||
export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
|
||||
const mode = value.type;
|
||||
const deviceTimeZone = useMemo(getDeviceTimeZone, []);
|
||||
const rememberedCronTimeZone = useRef(
|
||||
value.type === "cron" ? (value.timezone ?? "UTC") : deviceTimeZone,
|
||||
);
|
||||
const emittedIntervalCadenceKey = useRef<string | null>(null);
|
||||
const cronTimeZone =
|
||||
value.type === "cron" ? (value.timezone ?? "UTC") : rememberedCronTimeZone.current;
|
||||
|
||||
// The numeric/text fields are native-owned (AdaptiveTextInput). We seed them
|
||||
// once from the incoming cadence via lazy state initializers and bump
|
||||
@@ -88,6 +124,24 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
|
||||
value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (value.type === "cron") {
|
||||
emittedIntervalCadenceKey.current = null;
|
||||
rememberedCronTimeZone.current = value.timezone ?? "UTC";
|
||||
lastCronExpression.current = value.expression;
|
||||
return;
|
||||
}
|
||||
const cadenceKey = intervalCadenceKey(value);
|
||||
if (emittedIntervalCadenceKey.current === cadenceKey) {
|
||||
emittedIntervalCadenceKey.current = null;
|
||||
return;
|
||||
}
|
||||
rememberedCronTimeZone.current = deviceTimeZone;
|
||||
lastCronExpression.current = DEFAULT_CRON_EXPRESSION;
|
||||
setCronText(DEFAULT_CRON_EXPRESSION);
|
||||
bumpFieldResetKey();
|
||||
}, [deviceTimeZone, value]);
|
||||
|
||||
const parsedIntervalValue = useMemo(() => {
|
||||
const parsed = Number.parseInt(intervalValueText, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
||||
@@ -95,17 +149,24 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
|
||||
|
||||
const emitInterval = useCallback(
|
||||
(rawValue: number, unit: IntervalUnit) => {
|
||||
onChange({ type: "every", everyMs: partsToEveryMs(rawValue, unit) });
|
||||
if (value.type === "cron") {
|
||||
rememberedCronTimeZone.current = value.timezone ?? "UTC";
|
||||
}
|
||||
const next = { type: "every" as const, everyMs: partsToEveryMs(rawValue, unit) };
|
||||
emittedIntervalCadenceKey.current = intervalCadenceKey(next);
|
||||
onChange(next);
|
||||
},
|
||||
[onChange],
|
||||
[onChange, value],
|
||||
);
|
||||
|
||||
const emitCron = useCallback(
|
||||
(expression: string) => {
|
||||
lastCronExpression.current = expression;
|
||||
onChange({ type: "cron", expression });
|
||||
const next = nextCronCadence(value, expression, rememberedCronTimeZone.current);
|
||||
rememberedCronTimeZone.current = next.timezone ?? "UTC";
|
||||
onChange(next);
|
||||
},
|
||||
[onChange],
|
||||
[onChange, value],
|
||||
);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
@@ -161,7 +222,7 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
|
||||
const intervalPreview = describeInterval(parsedIntervalValue, intervalUnit);
|
||||
const trimmedCron = cronText.trim();
|
||||
const cronError = trimmedCron ? validateCron(trimmedCron) : null;
|
||||
const cronPreview = cronError ? null : (describeCron(trimmedCron) ?? trimmedCron);
|
||||
const cronPreview = getCronPreview(trimmedCron, cronTimeZone, cronError);
|
||||
|
||||
let cronFeedback: ReactNode = null;
|
||||
if (cronError) {
|
||||
@@ -231,7 +292,7 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
|
||||
style={styles.cronInput}
|
||||
/>
|
||||
{cronFeedback}
|
||||
<Text style={styles.hint}>Times are in UTC</Text>
|
||||
<Text style={styles.hint}>Times are in {cronTimeZone}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function getDeviceTimeZone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nextCronCadence } from "./schedule-cadence-policy";
|
||||
|
||||
describe("nextCronCadence", () => {
|
||||
it("preserves an existing cron cadence timezone when editing the expression", () => {
|
||||
expect(
|
||||
nextCronCadence(
|
||||
{
|
||||
type: "cron",
|
||||
expression: "0 9 * * *",
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
"30 9 * * *",
|
||||
"America/Los_Angeles",
|
||||
),
|
||||
).toEqual({
|
||||
type: "cron",
|
||||
expression: "30 9 * * *",
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits UTC for legacy cron cadences without a timezone", () => {
|
||||
expect(
|
||||
nextCronCadence(
|
||||
{
|
||||
type: "cron",
|
||||
expression: "0 9 * * *",
|
||||
},
|
||||
"30 9 * * *",
|
||||
"America/Los_Angeles",
|
||||
),
|
||||
).toEqual({
|
||||
type: "cron",
|
||||
expression: "30 9 * * *",
|
||||
timezone: "UTC",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the device timezone when switching from interval to cron", () => {
|
||||
expect(
|
||||
nextCronCadence(
|
||||
{
|
||||
type: "every",
|
||||
everyMs: 60 * 60_000,
|
||||
},
|
||||
"0 9 * * *",
|
||||
"America/Los_Angeles",
|
||||
),
|
||||
).toEqual({
|
||||
type: "cron",
|
||||
expression: "0 9 * * *",
|
||||
timezone: "America/Los_Angeles",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets callers preserve a remembered cron timezone when toggling back from interval", () => {
|
||||
expect(
|
||||
nextCronCadence(
|
||||
{
|
||||
type: "every",
|
||||
everyMs: 60 * 60_000,
|
||||
},
|
||||
"0 9 * * *",
|
||||
"America/New_York",
|
||||
),
|
||||
).toEqual({
|
||||
type: "cron",
|
||||
expression: "0 9 * * *",
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
|
||||
|
||||
export function nextCronCadence(
|
||||
current: ScheduleCadence,
|
||||
expression: string,
|
||||
deviceTimeZone: string,
|
||||
): CronCadence {
|
||||
if (current.type === "cron") {
|
||||
return { type: "cron", expression, timezone: current.timezone ?? "UTC" };
|
||||
}
|
||||
return { type: "cron", expression, timezone: deviceTimeZone };
|
||||
}
|
||||
@@ -85,17 +85,38 @@ describe("interval formatting", () => {
|
||||
|
||||
describe("describeCron", () => {
|
||||
it("humanizes common fixed-time cron shapes", () => {
|
||||
expect(describeCron("0 * * * *")).toBe("Every hour");
|
||||
expect(describeCron("15 * * * *")).toBe("Every hour at :15");
|
||||
expect(describeCron("0 9 * * *")).toBe("Daily at 09:00 UTC");
|
||||
expect(describeCron("0 9 * * 1-5")).toBe("Weekdays at 09:00 UTC");
|
||||
expect(describeCron("0 9 * * 0,6")).toBe("Weekends at 09:00 UTC");
|
||||
expect(describeCron("0 9 * * 1")).toBe("Mondays at 09:00 UTC");
|
||||
expect(describeCron({ type: "cron", expression: "0 * * * *" })).toBe("Every hour");
|
||||
expect(describeCron({ type: "cron", expression: "15 * * * *" })).toBe("Every hour at :15");
|
||||
expect(describeCron({ type: "cron", expression: "0 9 * * *" })).toBe("Daily at 09:00 UTC");
|
||||
expect(describeCron({ type: "cron", expression: "0 9 * * 1-5" })).toBe("Weekdays at 09:00 UTC");
|
||||
expect(describeCron({ type: "cron", expression: "0 9 * * 0,6" })).toBe("Weekends at 09:00 UTC");
|
||||
expect(describeCron({ type: "cron", expression: "0 9 * * 1" })).toBe("Mondays at 09:00 UTC");
|
||||
});
|
||||
|
||||
it("labels fixed-time cron cadences with their stored timezone", () => {
|
||||
expect(
|
||||
describeCron({
|
||||
type: "cron",
|
||||
expression: "0 9 * * *",
|
||||
timezone: "America/New_York",
|
||||
}),
|
||||
).toBe("Daily at 09:00 America/New_York");
|
||||
expect(
|
||||
formatCadence({
|
||||
type: "cron",
|
||||
expression: "0 9 * * 1-5",
|
||||
timezone: "Europe/Madrid",
|
||||
}),
|
||||
).toBe("Weekdays at 09:00 Europe/Madrid");
|
||||
});
|
||||
|
||||
it("keeps timezone-less fixed-time cron cadences labeled as UTC", () => {
|
||||
expect(formatCadence({ type: "cron", expression: "0 9 * * *" })).toBe("Daily at 09:00 UTC");
|
||||
});
|
||||
|
||||
it("returns null for invalid or unrecognized valid cron expressions", () => {
|
||||
expect(describeCron("not a cron")).toBeNull();
|
||||
expect(describeCron("*/5 * * * *")).toBeNull();
|
||||
expect(describeCron({ type: "cron", expression: "not a cron" })).toBeNull();
|
||||
expect(describeCron({ type: "cron", expression: "*/5 * * * *" })).toBeNull();
|
||||
expect(formatCadence({ type: "cron", expression: "0 9 * * *" })).toBe("Daily at 09:00 UTC");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedu
|
||||
import { validateCronExpression } from "@getpaseo/protocol/schedule/cron-expression";
|
||||
|
||||
export type IntervalUnit = "minutes" | "hours" | "days";
|
||||
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
|
||||
|
||||
const MS_PER_MINUTE = 60_000;
|
||||
const MS_PER_HOUR = MS_PER_MINUTE * 60;
|
||||
@@ -82,7 +83,7 @@ export function formatCadence(cadence: ScheduleCadence): string {
|
||||
if (cadence.type === "every") {
|
||||
return formatEvery(cadence.everyMs);
|
||||
}
|
||||
return describeCron(cadence.expression) ?? cadence.expression;
|
||||
return describeCron(cadence) ?? cadence.expression;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,8 +91,8 @@ export function formatCadence(cadence: ScheduleCadence): string {
|
||||
* expression is valid but not one of the recognized patterns (callers fall
|
||||
* back to showing the raw expression).
|
||||
*/
|
||||
export function describeCron(expr: string): string | null {
|
||||
const trimmed = expr.trim();
|
||||
export function describeCron(cadence: CronCadence): string | null {
|
||||
const trimmed = cadence.expression.trim();
|
||||
if (validateCron(trimmed) !== null) {
|
||||
return null;
|
||||
}
|
||||
@@ -121,20 +122,21 @@ export function describeCron(expr: string): string | null {
|
||||
return null;
|
||||
}
|
||||
const time = `${pad2(Number.parseInt(hour, 10))}:${pad2(minuteNum)}`;
|
||||
const timezone = cadence.timezone ?? "UTC";
|
||||
|
||||
if (dayOfWeek === "*") {
|
||||
return `Daily at ${time} UTC`;
|
||||
return `Daily at ${time} ${timezone}`;
|
||||
}
|
||||
if (dayOfWeek === "1-5") {
|
||||
return `Weekdays at ${time} UTC`;
|
||||
return `Weekdays at ${time} ${timezone}`;
|
||||
}
|
||||
if (dayOfWeek === "0,6" || dayOfWeek === "6,0") {
|
||||
return `Weekends at ${time} UTC`;
|
||||
return `Weekends at ${time} ${timezone}`;
|
||||
}
|
||||
if (/^\d$/.test(dayOfWeek)) {
|
||||
const day = DAY_NAMES[Number.parseInt(dayOfWeek, 10)];
|
||||
if (day) {
|
||||
return `${day}s at ${time} UTC`;
|
||||
return `${day}s at ${time} ${timezone}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user