diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 22756ff9..f1797c79 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -495,6 +495,26 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(restoredTrigger?.publicId).not.toBe(created.trigger.publicId); }); + it("persists custom schedule cron expressions exactly", async () => { + const { companyId, routine, svc } = await seedFixture(); + const cronExpression = "0 8-18/2 * * 1-5"; + + const created = await svc.createTrigger(routine.id, { + kind: "schedule", + label: "Business hours", + cronExpression, + timezone: "UTC", + }, {}); + + expect(created.trigger.cronExpression).toBe(cronExpression); + + const storedTrigger = await svc.getTrigger(created.trigger.id); + expect(storedTrigger?.cronExpression).toBe(cronExpression); + + const [listed] = await svc.list(companyId); + expect(listed?.triggers[0]?.cronExpression).toBe(cronExpression); + }); + it("blocks agents from restoring routine revisions assigned to another agent", async () => { const { companyId, routine, svc } = await seedFixture(); const otherAgentId = randomUUID(); diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 5d1b7b4a..799c000c 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -353,6 +353,57 @@ describe("IssueChatThread", () => { }); }); + it("labels operator-interrupted cancelled runs as interrupted while preserving plain cancelled runs", () => { + const root = createRoot(container); + const linkedRuns: IssueChatLinkedRun[] = [ + { + runId: "run-interrupted", + status: "cancelled", + agentId: "agent-1", + agentName: "CodexCoder", + createdAt: new Date("2026-04-06T12:00:00.000Z"), + startedAt: new Date("2026-04-06T12:00:00.000Z"), + finishedAt: new Date("2026-04-06T12:01:00.000Z"), + errorCode: "operator_interrupted", + resultJson: { operatorInterrupted: true, interruptionSource: "issue_comment_interrupt" }, + }, + { + runId: "run-cancelled", + status: "cancelled", + agentId: "agent-1", + agentName: "CodexCoder", + createdAt: new Date("2026-04-06T12:02:00.000Z"), + startedAt: new Date("2026-04-06T12:02:00.000Z"), + finishedAt: new Date("2026-04-06T12:03:00.000Z"), + resultJson: { stopReason: "cancelled" }, + }, + ]; + + act(() => { + root.render( + + {}} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.textContent).toContain("interrupted by board after 1 minute"); + expect(container.textContent).toContain("cancelled after 1 minute"); + expect(container.textContent).not.toContain("run interrupted"); + + act(() => { + root.unmount(); + }); + }); + it("falls back to execCommand for comment copy actions in insecure contexts", async () => { const clipboardWrite = vi.fn(async () => { throw new Error("Clipboard API blocked"); diff --git a/ui/src/components/IssueChatThreadClassic.tsx b/ui/src/components/IssueChatThreadClassic.tsx index fc68fb92..eb4736cb 100644 --- a/ui/src/components/IssueChatThreadClassic.tsx +++ b/ui/src/components/IssueChatThreadClassic.tsx @@ -107,6 +107,7 @@ import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { IssueThreadInteractionCardClassic } from "./IssueThreadInteractionCardClassic"; import { AgentIcon } from "./AgentIconPicker"; +import { RunStatusBadge } from "./interrupt-handoff/InterruptHandoffViews"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { captureComposerViewportSnapshot, @@ -793,36 +794,6 @@ export function resolveIssueChatHumanAuthor(args: { }; } -function formatRunStatusLabel(status: string) { - switch (status) { - case "timed_out": - return "timed out"; - default: - return status.replace(/_/g, " "); - } -} - -function runStatusClass(status: string) { - switch (status) { - case "succeeded": - return "text-green-700 dark:text-green-300"; - case "failed": - case "error": - return "text-red-700 dark:text-red-300"; - case "timed_out": - return "text-orange-700 dark:text-orange-300"; - case "running": - return "text-cyan-700 dark:text-cyan-300"; - case "queued": - case "pending": - return "text-amber-700 dark:text-amber-300"; - case "cancelled": - return "text-muted-foreground"; - default: - return "text-foreground"; - } -} - function toolCountSummary(toolParts: ToolCallMessagePart[]): string | null { if (toolParts.length === 0) return null; let commands = 0; @@ -2717,9 +2688,10 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { > {runId.slice(0, 8)} - - {formatRunStatusLabel(runStatus)} - + void; + onValidityChange?: (valid: boolean) => void; +}) { + const [value, setValue] = useState(initial); + return ( + { + setValue(cron); + onChange?.(cron); + }} + /> + ); +} + +function typeCron(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +function act(callback: () => void) { + flushSync(callback); +} + +describe("ScheduleEditor cron helpers", () => { + it("classifies unknown valid 5-field cron expressions as custom", () => { + expect(parseCronToPreset("0 8-18/2 * * 1-5").preset).toBe("custom"); + expect(getScheduleCronValidation("0 8-18/2 * * 1-5").valid).toBe(true); + }); + + it("still recognizes supported presets and emits their expected cron", () => { + expect(parseCronToPreset("0 10 * * 1-5")).toMatchObject({ + preset: "weekdays", + hour: "10", + minute: "0", + }); + expect(buildCron("weekdays", "8", "15", "1", "1")).toBe("15 8 * * 1-5"); + }); + + it("reports partial cron edits as invalid without treating them as presets", () => { + const validation = getScheduleCronValidation("0 8-18/2 *"); + expect(validation.valid).toBe(false); + expect(validation.message).toContain("5 fields"); + }); +}); + +describe("ScheduleEditor", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + }); + + function cronInput() { + return container.querySelector('input[aria-label="Cron expression"]'); + } + + it("renders unknown valid cron expressions in Custom with the original text", () => { + const root = createRoot(container); + act(() => { + root.render(); + }); + + expect(container.textContent).toContain("Custom (cron)"); + expect(cronInput()?.value).toBe("0 8-18/2 * * 1-5"); + expect(container.textContent).toContain("Valid cron."); + + act(() => root.unmount()); + }); + + it("keeps Custom open while partial edits and pasted valid cron values round-trip through parent state", () => { + const onChange = vi.fn(); + const onValidityChange = vi.fn(); + const root = createRoot(container); + act(() => { + root.render( + , + ); + }); + + act(() => { + typeCron(cronInput()!, "0 8-18/2 *"); + }); + expect(cronInput()?.value).toBe("0 8-18/2 *"); + expect(cronInput()?.getAttribute("aria-invalid")).toBe("true"); + expect(container.textContent).toContain("Use exactly 5 fields"); + expect(onChange).not.toHaveBeenCalledWith("0 8-18/2 *"); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + + act(() => { + typeCron(cronInput()!, "0 8-18/2 * * 1-5"); + }); + expect(cronInput()?.value).toBe("0 8-18/2 * * 1-5"); + expect(cronInput()?.getAttribute("aria-invalid")).toBe("false"); + expect(onChange).toHaveBeenLastCalledWith("0 8-18/2 * * 1-5"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/ScheduleEditor.tsx b/ui/src/components/ScheduleEditor.tsx index e5a1bc63..2d991d5b 100644 --- a/ui/src/components/ScheduleEditor.tsx +++ b/ui/src/components/ScheduleEditor.tsx @@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; -import { ChevronDown, ChevronRight } from "lucide-react"; +import { nextCronFires, parseCronExpression } from "../lib/cron-fires"; -type SchedulePreset = "every_minute" | "every_hour" | "every_day" | "weekdays" | "weekly" | "monthly" | "custom"; +export type SchedulePreset = "every_minute" | "every_hour" | "every_day" | "weekdays" | "weekly" | "monthly" | "custom"; const PRESETS: { value: SchedulePreset; label: string }[] = [ { value: "every_minute", label: "Every minute" }, @@ -41,7 +41,11 @@ const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => ({ label: String(i + 1), })); -function parseCronToPreset(cron: string): { +function hasOption(options: Array<{ value: string }>, value: string): boolean { + return options.some((option) => option.value === value); +} + +export function parseCronToPreset(cron: string): { preset: SchedulePreset; hour: string; minute: string; @@ -59,42 +63,44 @@ function parseCronToPreset(cron: string): { return { preset: "custom", ...defaults }; } - const [min, hr, dom, , dow] = parts; + const [min, hr, dom, month, dow] = parts; + const selectableMinute = hasOption(MINUTES, min); + const selectableHour = hasOption(HOURS, hr); // Every minute: "* * * * *" - if (min === "*" && hr === "*" && dom === "*" && dow === "*") { + if (min === "*" && hr === "*" && dom === "*" && month === "*" && dow === "*") { return { preset: "every_minute", ...defaults }; } // Every hour: "0 * * * *" - if (hr === "*" && dom === "*" && dow === "*") { - return { preset: "every_hour", ...defaults, minute: min === "*" ? "0" : min }; + if (hr === "*" && dom === "*" && month === "*" && dow === "*" && selectableMinute) { + return { preset: "every_hour", ...defaults, minute: min }; } // Every day: "M H * * *" - if (dom === "*" && dow === "*" && hr !== "*") { - return { preset: "every_day", ...defaults, hour: hr, minute: min === "*" ? "0" : min }; + if (dom === "*" && month === "*" && dow === "*" && selectableHour && selectableMinute) { + return { preset: "every_day", ...defaults, hour: hr, minute: min }; } // Weekdays: "M H * * 1-5" - if (dom === "*" && dow === "1-5" && hr !== "*") { - return { preset: "weekdays", ...defaults, hour: hr, minute: min === "*" ? "0" : min }; + if (dom === "*" && month === "*" && dow === "1-5" && selectableHour && selectableMinute) { + return { preset: "weekdays", ...defaults, hour: hr, minute: min }; } // Weekly: "M H * * D" (single day) - if (dom === "*" && /^\d$/.test(dow) && hr !== "*") { - return { preset: "weekly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfWeek: dow }; + if (dom === "*" && month === "*" && hasOption(DAYS_OF_WEEK, dow) && selectableHour && selectableMinute) { + return { preset: "weekly", ...defaults, hour: hr, minute: min, dayOfWeek: dow }; } // Monthly: "M H D * *" - if (/^\d{1,2}$/.test(dom) && dow === "*" && hr !== "*") { - return { preset: "monthly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfMonth: dom }; + if (month === "*" && hasOption(DAYS_OF_MONTH, dom) && dow === "*" && selectableHour && selectableMinute) { + return { preset: "monthly", ...defaults, hour: hr, minute: min, dayOfMonth: dom }; } return { preset: "custom", ...defaults }; } -function buildCron(preset: SchedulePreset, hour: string, minute: string, dayOfWeek: string, dayOfMonth: string): string { +export function buildCron(preset: SchedulePreset, hour: string, minute: string, dayOfWeek: string, dayOfMonth: string): string { switch (preset) { case "every_minute": return "* * * * *"; @@ -146,12 +152,53 @@ function ordinalSuffix(n: number): string { export { describeSchedule }; +export function getScheduleCronValidation(cron: string): { + valid: boolean; + message: string; + nextFires: Date[]; +} { + const trimmed = cron.trim(); + if (!trimmed) { + return { + valid: false, + message: "Enter a 5-field cron expression.", + nextFires: [], + }; + } + + const fields = trimmed.split(/\s+/); + if (fields.length !== 5) { + return { + valid: false, + message: `Use exactly 5 fields; this has ${fields.length}.`, + nextFires: [], + }; + } + + if (!parseCronExpression(trimmed)) { + return { + valid: false, + message: "Cron fields must use valid numbers, ranges, lists, wildcards, or steps.", + nextFires: [], + }; + } + + const nextFires = nextCronFires(trimmed, 3, { timeZone: "UTC" }); + return { + valid: true, + message: nextFires.length > 0 ? "Valid cron." : "Valid cron, but no upcoming fires were found.", + nextFires, + }; +} + export function ScheduleEditor({ value, onChange, + onValidityChange, }: { value: string; onChange: (cron: string) => void; + onValidityChange?: (valid: boolean) => void; }) { const parsed = useMemo(() => parseCronToPreset(value), [value]); const [preset, setPreset] = useState(parsed.preset); @@ -160,6 +207,11 @@ export function ScheduleEditor({ const [dayOfWeek, setDayOfWeek] = useState(parsed.dayOfWeek); const [dayOfMonth, setDayOfMonth] = useState(parsed.dayOfMonth); const [customCron, setCustomCron] = useState(preset === "custom" ? value : ""); + const customValidation = useMemo(() => getScheduleCronValidation(customCron), [customCron]); + + useEffect(() => { + onValidityChange?.(preset !== "custom" || customValidation.valid); + }, [customValidation.valid, onValidityChange, preset]); // Sync from external value changes useEffect(() => { @@ -195,7 +247,7 @@ export function ScheduleEditor({ return (
{ - setCustomCron(e.target.value); - emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, e.target.value); + const nextCron = e.target.value; + setCustomCron(nextCron); + if (getScheduleCronValidation(nextCron).valid) { + emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, nextCron); + } }} placeholder="0 10 * * *" + aria-label="Cron expression" + aria-invalid={!customValidation.valid} className="font-mono text-sm" />

Five fields: minute hour day-of-month month day-of-week

+

+ {customValidation.message} + {customValidation.valid && customValidation.nextFires.length > 0 + ? ` Next: ${customValidation.nextFires.map((fire) => fire.toLocaleString()).join(", ")}.` + : null} +

) : (
diff --git a/ui/src/components/routine-sections/context.tsx b/ui/src/components/routine-sections/context.tsx index 7f969f9d..332b8437 100644 --- a/ui/src/components/routine-sections/context.tsx +++ b/ui/src/components/routine-sections/context.tsx @@ -67,6 +67,15 @@ export type NewTriggerDraft = { replayWindowSec: string; }; +export function createDefaultNewTrigger(): NewTriggerDraft { + return { + kind: "schedule", + cronExpression: "0 10 * * *", + signingMode: "bearer", + replayWindowSec: "300", + }; +} + export type SecretMessage = { title: string; entries: Array<{ webhookUrl: string; webhookSecret: string }>; diff --git a/ui/src/components/routine-sections/editable-sections.test.tsx b/ui/src/components/routine-sections/editable-sections.test.tsx new file mode 100644 index 00000000..978e2d32 --- /dev/null +++ b/ui/src/components/routine-sections/editable-sections.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom + +import { useState } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TriggersSection } from "./editable-sections"; +import { + RoutineDetailContext, + createDefaultNewTrigger, + type NewTriggerDraft, + type RoutineDetailContextValue, +} from "./context"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("../MarkdownEditor", () => ({ + MarkdownEditor: () => null, +})); + +function act(callback: () => void) { + flushSync(callback); +} + +function buttonByText(container: HTMLElement, label: string): HTMLButtonElement { + const button = [...container.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === label, + ); + if (!button) throw new Error(`Button not found: ${label}`); + return button as HTMLButtonElement; +} + +function typeCron(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +function Harness({ createMutate }: { createMutate: ReturnType }) { + const [newTrigger, setNewTrigger] = useState({ + ...createDefaultNewTrigger(), + cronExpression: "0 8-18/2 * * 1-5", + }); + + const value = { + routine: { + id: "routine-1", + triggers: [], + }, + newTrigger, + setNewTrigger, + createTrigger: { + isPending: false, + mutate: createMutate, + }, + updateTrigger: { mutate: vi.fn() }, + deleteTrigger: { mutate: vi.fn() }, + rotateTrigger: { mutate: vi.fn() }, + } as unknown as RoutineDetailContextValue; + + return ( + + + + ); +} + +describe("TriggersSection", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + }); + + it("closes the add-trigger composer and resets the draft after a successful create", () => { + const createMutate = vi.fn((_variables, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.(); + }); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + act(() => { + buttonByText(container, "New trigger").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(container.querySelector('input[aria-label="Cron expression"]')).not.toBeNull(); + + act(() => { + buttonByText(container, "Add trigger").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(createMutate).toHaveBeenCalledTimes(1); + expect(container.querySelector('input[aria-label="Cron expression"]')).toBeNull(); + expect(buttonByText(container, "New trigger")).not.toBeNull(); + + act(() => { + buttonByText(container, "New trigger").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(container.querySelector('input[aria-label="Cron expression"]')).toBeNull(); + expect(container.textContent).toContain("Every day"); + + act(() => root.unmount()); + }); + + it("disables add trigger while the custom cron draft is invalid locally", () => { + const createMutate = vi.fn(); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + act(() => { + buttonByText(container, "New trigger").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const input = container.querySelector('input[aria-label="Cron expression"]'); + expect(input).not.toBeNull(); + expect(buttonByText(container, "Add trigger").disabled).toBe(false); + + act(() => { + typeCron(input!, "0 8-18/2 *"); + }); + + expect(input?.value).toBe("0 8-18/2 *"); + expect(container.textContent).toContain("Use exactly 5 fields"); + expect(buttonByText(container, "Add trigger").disabled).toBe(true); + + act(() => { + buttonByText(container, "Add trigger").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(createMutate).not.toHaveBeenCalled(); + + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/routine-sections/editable-sections.tsx b/ui/src/components/routine-sections/editable-sections.tsx index 0ab24040..8fb50579 100644 --- a/ui/src/components/routine-sections/editable-sections.tsx +++ b/ui/src/components/routine-sections/editable-sections.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ArrowRight, Braces, @@ -29,11 +29,11 @@ import { EmptyState } from "../EmptyState"; import { InlineEntitySelector } from "../InlineEntitySelector"; import { AgentIcon } from "../AgentIconPicker"; import { MarkdownEditor } from "../MarkdownEditor"; -import { ScheduleEditor } from "../ScheduleEditor"; +import { ScheduleEditor, getScheduleCronValidation } from "../ScheduleEditor"; import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor"; import { RoutineTriggerCard } from "../RoutineTriggerCard"; import { EnvVarEditor } from "../EnvVarEditor"; -import { useRoutineDetail } from "./context"; +import { createDefaultNewTrigger, useRoutineDetail } from "./context"; import type { EnvBinding, RoutineDetail as RoutineDetailType } from "@paperclipai/shared"; const concurrencyPolicyOptions = [ @@ -341,6 +341,18 @@ export function TriggersSection() { const ctx = useRoutineDetail(); const { routine, newTrigger, setNewTrigger, createTrigger, updateTrigger, deleteTrigger, rotateTrigger } = ctx; const [addOpen, setAddOpen] = useState(false); + const [newScheduleEditorValid, setNewScheduleEditorValid] = useState(true); + const newScheduleValidation = useMemo( + () => newTrigger.kind === "schedule" ? getScheduleCronValidation(newTrigger.cronExpression) : null, + [newTrigger.cronExpression, newTrigger.kind], + ); + const addDisabled = + createTrigger.isPending || + (newScheduleValidation ? !newScheduleValidation.valid || !newScheduleEditorValid : false); + + useEffect(() => { + if (newTrigger.kind !== "schedule") setNewScheduleEditorValid(true); + }, [newTrigger.kind]); return (
@@ -403,6 +415,7 @@ export function TriggersSection() { onChange={(cronExpression) => setNewTrigger((current) => ({ ...current, cronExpression })) } + onValidityChange={setNewScheduleEditorValid} />
)} @@ -451,8 +464,15 @@ export function TriggersSection() { diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index ca7d5433..f8428677 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -43,6 +43,7 @@ import { ROUTINE_SECTION_KEYS, SECTION_FIELD_KEYS, RoutineDetailContext, + createDefaultNewTrigger, type RoutineDetailContextValue, type RoutineEditDraft, type RoutineSectionKey, @@ -154,12 +155,7 @@ export function RoutineDetail() { const [secretMessage, setSecretMessage] = useState(null); const [saveConflict, setSaveConflict] = useState(false); const [runVariablesOpen, setRunVariablesOpen] = useState(false); - const [newTrigger, setNewTrigger] = useState({ - kind: "schedule", - cronExpression: "0 10 * * *", - signingMode: "bearer", - replayWindowSec: "300", - }); + const [newTrigger, setNewTrigger] = useState(createDefaultNewTrigger); const [editDraft, setEditDraft] = useState({ title: "", description: "",