Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ 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 @@ -59,52 +58,6 @@ 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: 1 addition & 4 deletions apps/server/src/scheduledTasks/ScheduledTaskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -936,10 +936,7 @@ export const layer = Layer.effect(

const list: ScheduledTaskService["Service"]["list"] = () =>
listRows().pipe(
Effect.map((tasks) => ({
tasks,
timeZone: DateTime.zoneToString(DateTime.zoneMakeLocal()),
})),
Effect.map((tasks) => ({ tasks })),
Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })),
);

Expand Down
1 change: 0 additions & 1 deletion apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1321,7 +1321,6 @@ 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,10 +1,4 @@
import {
EnvironmentId,
ThreadId,
ScheduledTaskId,
type ScheduledTask,
type ScheduledTaskListResult,
} from "@t3tools/contracts";
import { EnvironmentId, ThreadId, ScheduledTaskId, type ScheduledTask } 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 @@ -34,7 +28,7 @@ vi.mock(import("../../state/use-atom-command"), async (importOriginal) => {
});
vi.mock(import("../../state/query"), async (importOriginal) => ({
...(await importOriginal()),
useEnvironmentQuery: (() => ({ data: listResult, error: null })) as never,
useEnvironmentQuery: (() => ({ data: { tasks: [task] }, error: null })) as never,
}));

import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel";
Expand All @@ -47,15 +41,12 @@ 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 @@ -93,33 +84,3 @@ 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: 4 additions & 8 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, timeZone: string | undefined) {
function taskScheduleLine(task: ScheduledTask) {
return (
<>
{scheduleLabel(task.schedule, timeZone)}
{scheduleLabel(task.schedule)}
{task.enabled && task.nextRunAt !== null ? (
<>
{" · next "}
Expand Down Expand Up @@ -185,15 +185,11 @@ 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, tasksQuery.data?.timeZone)}
</p>
<p className="truncate text-2xs text-muted-foreground">{taskScheduleLine(task)}</p>
</TooltipTrigger>
<TooltipPopup side="left">
<p className="font-medium">{task.title}</p>
<p className="text-muted-foreground">
{taskScheduleLine(task, tasksQuery.data?.timeZone)}
</p>
<p className="text-muted-foreground">{taskScheduleLine(task)}</p>
</TooltipPopup>
</Tooltip>
<Tooltip>
Expand Down
22 changes: 4 additions & 18 deletions apps/web/src/components/settings/ScheduledTasksSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,11 +179,7 @@ function splitModelKey(value: string): ModelSelection | null {
};
}

function scheduleTimeZoneLabel(timeZone: string | undefined) {
return timeZone === "Etc/UTC" ? "UTC" : timeZone;
}

export function scheduleLabel(schedule: ScheduledTaskSchedule, timeZone?: string): string {
export function scheduleLabel(schedule: ScheduledTaskSchedule): string {
if (schedule.type === "webhook") return "On webhook";
if (schedule.type === "interval") {
const minutes = schedule.everyMs / 60_000;
Expand All @@ -198,8 +194,7 @@ export function scheduleLabel(schedule: ScheduledTaskSchedule, timeZone?: string
: weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5)
? "Weekdays"
: weekdays.map((day) => WEEKDAY_LABELS[day]).join(", ");
const zone = scheduleTimeZoneLabel(timeZone);
return `${days} at ${schedule.timeOfDay}${zone === undefined ? "" : ` ${zone}`}`;
return `${days} at ${schedule.timeOfDay}`;
}

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

return (
<SettingsGroup>
{populated.map(({ environment, tasks, timeZone }) => {
{populated.map(({ environment, tasks }) => {
const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments);
const transport = environmentTransportLabel(environment);
return (
Expand Down Expand Up @@ -441,7 +435,6 @@ function ScheduledTaskEnvironmentList({
key={task.id}
environmentId={environment.environmentId}
task={task}
timeZone={timeZone}
onEdit={() => onEdit(environment.environmentId, task)}
/>
))}
Expand Down Expand Up @@ -557,7 +550,6 @@ function ScheduledTaskEnvironmentSection({
key={task.id}
environmentId={environment.environmentId}
task={task}
timeZone={tasksQuery.data?.timeZone}
onEdit={() => onEdit(environment.environmentId, task)}
/>
))
Expand All @@ -571,12 +563,10 @@ 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 @@ -621,7 +611,7 @@ function ScheduledTaskRow({
status={
<div className="flex flex-wrap items-center gap-2">
<span>
{scheduleLabel(task.schedule, timeZone)} ·{" "}
{scheduleLabel(task.schedule)} ·{" "}
{!task.enabled ? (
"Paused"
) : isWebhook ? (
Expand Down Expand Up @@ -1008,7 +998,6 @@ 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 @@ -1512,9 +1501,6 @@ 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: 0 additions & 2 deletions packages/contracts/src/scheduledTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,6 @@ 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