Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/scheduledTasks/ScheduledTaskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
);

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,7 @@ export const layerWsRpc = (
currentSession.scopes.includes(AuthOrchestrationOperateScope)
? result
: {
...result,
tasks: result.tasks.map(({ webhook: _webhook, ...task }) => task),
};
const authorizeEffect = <A, E, R>(
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -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"]);
});
12 changes: 8 additions & 4 deletions apps/web/src/components/chat/ThreadAutomationsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ const STATUS_DOT_CLASS: Record<ScheduledTask["lastRunStatus"], string> = {
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 "}
Expand Down Expand Up @@ -185,11 +185,15 @@ export function ThreadAutomationsPanel(props: {
<span className="block truncate text-sm font-medium text-foreground/80">
{task.title}
</span>
<p className="truncate text-2xs text-muted-foreground">{taskScheduleLine(task)}</p>
<p className="truncate text-2xs text-muted-foreground">
{taskScheduleLine(task, tasksQuery.data?.timeZone)}
</p>
</TooltipTrigger>
<TooltipPopup side="left">
<p className="font-medium">{task.title}</p>
<p className="text-muted-foreground">{taskScheduleLine(task)}</p>
<p className="text-muted-foreground">
{taskScheduleLine(task, tasksQuery.data?.timeZone)}
</p>
</TooltipPopup>
</Tooltip>
<Tooltip>
Expand Down
22 changes: 18 additions & 4 deletions apps/web/src/components/settings/ScheduledTasksSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}`}`;
}

/**
Expand Down Expand Up @@ -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),
),
Expand Down Expand Up @@ -405,7 +411,7 @@ function ScheduledTaskEnvironmentList({

return (
<SettingsGroup>
{populated.map(({ environment, tasks }) => {
{populated.map(({ environment, tasks, timeZone }) => {
const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments);
const transport = environmentTransportLabel(environment);
return (
Expand Down Expand Up @@ -435,6 +441,7 @@ function ScheduledTaskEnvironmentList({
key={task.id}
environmentId={environment.environmentId}
task={task}
timeZone={timeZone}
onEdit={() => onEdit(environment.environmentId, task)}
/>
))}
Expand Down Expand Up @@ -550,6 +557,7 @@ function ScheduledTaskEnvironmentSection({
key={task.id}
environmentId={environment.environmentId}
task={task}
timeZone={tasksQuery.data?.timeZone}
onEdit={() => onEdit(environment.environmentId, task)}
/>
))
Expand All @@ -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(
Expand Down Expand Up @@ -611,7 +621,7 @@ function ScheduledTaskRow({
status={
<div className="flex flex-wrap items-center gap-2">
<span>
{scheduleLabel(task.schedule)} ·{" "}
{scheduleLabel(task.schedule, timeZone)} ·{" "}
{!task.enabled ? (
"Paused"
) : isWebhook ? (
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1501,6 +1512,9 @@ function ScheduledTaskEditorForm({
setDraft((current) => ({ ...current, timeOfDay: event.target.value }))
}
/>
{runAtZone === undefined ? null : (
<span className="text-xs text-muted-foreground">{runAtZone}</span>
)}
<span className="text-xs text-muted-foreground">on</span>
</div>
<ToggleGroup
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/scheduledTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ export const ScheduledTaskListResult = Schema.Struct({
// Trigger types grow over time; a client must not lose the whole list over
// one task it cannot decode.
tasks: ForwardCompatibleArray(ScheduledTask),
/** The server's IANA zone for fixed-time schedules. Absent from older servers. */
timeZone: Schema.optional(TrimmedNonEmptyString),
});
export type ScheduledTaskListResult = typeof ScheduledTaskListResult.Type;

Expand Down
Loading