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:
Mohamed Boudra
2026-07-07 17:13:28 +08:00
committed by GitHub
parent 783f43c580
commit d3e1fe48f6
29 changed files with 4814 additions and 1067 deletions
@@ -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 };
}
+29 -8
View File
@@ -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");
});
});
+9 -7
View File
@@ -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;