diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index bcb110a5da71..91d58d53b684 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -6,6 +6,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; @@ -58,6 +59,52 @@ it.effect("rejects a stale form save after deletion while preserving explicit-id }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); +it.effect("includes the scheduler zone in empty lists and subscription updates", () => + Effect.gen(function* () { + const layerDependencies = Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const timeZone = DateTime.zoneToString(DateTime.zoneMakeLocal()); + expect(yield* service.list()).toEqual({ tasks: [], timeZone }); + + const input = yield* decodeUpsertInput({ + title: "Morning review", + prompt: "Review the open pull requests.", + enabled: false, + schedule: { type: "fixed_time", timeOfDay: "09:00" }, + projectId: "project-schedule-zone", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + const snapshots = yield* service.subscribeList().pipe( + Stream.tap((snapshot) => + snapshot.tasks.length === 0 ? service.upsert(input) : Effect.void, + ), + Stream.take(2), + Stream.runCollect, + ); + const populated = yield* service.list(); + expect(populated).toMatchObject({ + timeZone, + tasks: [{ title: "Morning review", schedule: input.schedule }], + }); + expect(snapshots).toEqual([{ tasks: [], timeZone }, populated]); + }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + it.effect("preserves a due run when a save only pads the scheduled hour", () => Effect.gen(function* () { const dueAt = DateTime.makeZonedUnsafe( diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index f00e19367830..7255af89fcaa 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -927,7 +927,10 @@ export const layer = Layer.effect( const list: ScheduledTaskService["Service"]["list"] = () => listRows().pipe( - Effect.map((tasks) => ({ tasks })), + Effect.map((tasks) => ({ + tasks, + timeZone: DateTime.zoneToString(DateTime.zoneMakeLocal()), + })), Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 48b8290f196d..7dc60532204c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1294,6 +1294,7 @@ export const layerWsRpc = ( currentSession.scopes.includes(AuthOrchestrationOperateScope) ? result : { + ...result, tasks: result.tasks.map(({ webhook: _webhook, ...task }) => task), }; const authorizeEffect = ( diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx index e780a6350756..0d38057c6545 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx @@ -1,4 +1,10 @@ -import { EnvironmentId, ThreadId, ScheduledTaskId, type ScheduledTask } from "@t3tools/contracts"; +import { + EnvironmentId, + ThreadId, + ScheduledTaskId, + type ScheduledTask, + type ScheduledTaskListResult, +} from "@t3tools/contracts"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; @@ -28,7 +34,7 @@ vi.mock(import("../../state/use-atom-command"), async (importOriginal) => { }); vi.mock(import("../../state/query"), async (importOriginal) => ({ ...(await importOriginal()), - useEnvironmentQuery: (() => ({ data: { tasks: [task] }, error: null })) as never, + useEnvironmentQuery: (() => ({ data: listResult, error: null })) as never, })); import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; @@ -41,12 +47,15 @@ const task = { enabled: true, schedule: { type: "interval", everyMs: 60000 }, lastRunStatus: "never", + nextRunAt: null, } as ScheduledTask; +let listResult: ScheduledTaskListResult & { timeZone?: string }; let root: Root; let container: HTMLDivElement; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); state.allowed = true; + listResult = { tasks: [task] }; state.toggle.mockReset().mockResolvedValue({ _tag: "Success" }); state.run.mockReset().mockResolvedValue({ _tag: "Success" }); container = document.createElement("div"); @@ -84,3 +93,33 @@ it("rejects a retained toggle after task permission is revoked", async () => { await act(async () => byLabel("Pause Daily review").click()); expect(state.toggle).toHaveBeenCalledOnce(); }); + +it("labels fixed times with the server zone and preserves labels from older servers", async () => { + const tasks: ScheduledTask[] = [ + { ...task, schedule: { type: "fixed_time", timeOfDay: "09:00" } }, + { ...task, id: ScheduledTaskId.make("interval"), title: "Interval review" }, + { + ...task, + id: ScheduledTaskId.make("webhook"), + title: "Webhook review", + schedule: { type: "webhook", signature: null }, + }, + ]; + const labels = () => Array.from(container.querySelectorAll("li p"), (row) => row.textContent); + + listResult = { tasks, timeZone: "Asia/Kolkata" }; + await renderPanel(); + expect(labels()).toEqual(["Daily at 09:00 Asia/Kolkata", "Every 1 min", "On webhook"]); + + listResult = { tasks, timeZone: "Etc/UTC" }; + await renderPanel(); + expect(labels()).toEqual(["Daily at 09:00 UTC", "Every 1 min", "On webhook"]); + + listResult = { tasks, timeZone: "UTC" }; + await renderPanel(); + expect(labels()).toEqual(["Daily at 09:00 UTC", "Every 1 min", "On webhook"]); + + listResult = { tasks }; + await renderPanel(); + expect(labels()).toEqual(["Daily at 09:00", "Every 1 min", "On webhook"]); +}); diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 28fb297b3526..670ea8db3066 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -37,10 +37,10 @@ const STATUS_DOT_CLASS: Record = { failed: "bg-destructive", }; -function taskScheduleLine(task: ScheduledTask) { +function taskScheduleLine(task: ScheduledTask, timeZone: string | undefined) { return ( <> - {scheduleLabel(task.schedule)} + {scheduleLabel(task.schedule, timeZone)} {task.enabled && task.nextRunAt !== null ? ( <> {" · next "} @@ -185,11 +185,15 @@ export function ThreadAutomationsPanel(props: { {task.title} -

{taskScheduleLine(task)}

+

+ {taskScheduleLine(task, tasksQuery.data?.timeZone)} +

{task.title}

-

{taskScheduleLine(task)}

+

+ {taskScheduleLine(task, tasksQuery.data?.timeZone)} +

diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index c22bdc188dbd..98988c3d278c 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -179,7 +179,11 @@ function splitModelKey(value: string): ModelSelection | null { }; } -export function scheduleLabel(schedule: ScheduledTaskSchedule): string { +function scheduleTimeZoneLabel(timeZone: string | undefined) { + return timeZone === "Etc/UTC" ? "UTC" : timeZone; +} + +export function scheduleLabel(schedule: ScheduledTaskSchedule, timeZone?: string): string { if (schedule.type === "webhook") return "On webhook"; if (schedule.type === "interval") { const minutes = schedule.everyMs / 60_000; @@ -194,7 +198,8 @@ export function scheduleLabel(schedule: ScheduledTaskSchedule): string { : weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5) ? "Weekdays" : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); - return `${days} at ${schedule.timeOfDay}`; + const zone = scheduleTimeZoneLabel(timeZone); + return `${days} at ${schedule.timeOfDay}${zone === undefined ? "" : ` ${zone}`}`; } /** @@ -364,6 +369,7 @@ function ScheduledTaskEnvironmentList({ return { environment, connected: index >= 0, + timeZone: data?.timeZone, tasks: data?.tasks.filter((task) => matchesScheduledTaskScope(scope, environment.environmentId, task.projectId), ), @@ -405,7 +411,7 @@ function ScheduledTaskEnvironmentList({ return ( - {populated.map(({ environment, tasks }) => { + {populated.map(({ environment, tasks, timeZone }) => { const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments); const transport = environmentTransportLabel(environment); return ( @@ -435,6 +441,7 @@ function ScheduledTaskEnvironmentList({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> ))} @@ -550,6 +557,7 @@ function ScheduledTaskEnvironmentSection({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={tasksQuery.data?.timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> )) @@ -563,10 +571,12 @@ function ScheduledTaskEnvironmentSection({ function ScheduledTaskRow({ environmentId, task, + timeZone, onEdit, }: { readonly environmentId: EnvironmentId; readonly task: ScheduledTask; + readonly timeZone: string | undefined; readonly onEdit: () => void; }) { const canOperate = useAtomValue( @@ -611,7 +621,7 @@ function ScheduledTaskRow({ status={
- {scheduleLabel(task.schedule)} ·{" "} + {scheduleLabel(task.schedule, timeZone)} ·{" "} {!task.enabled ? ( "Paused" ) : isWebhook ? ( @@ -998,6 +1008,7 @@ function ScheduledTaskEditorForm({ draft.editingId !== null && tasksQuery.data !== null && !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId); + const runAtZone = scheduleTimeZoneLabel(tasksQuery.data?.timeZone); const liveTask = tasksQuery.data ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null) : task; @@ -1501,6 +1512,9 @@ function ScheduledTaskEditorForm({ setDraft((current) => ({ ...current, timeOfDay: event.target.value })) } /> + {runAtZone === undefined ? null : ( + {runAtZone} + )} on