diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 64ba04cf2b3c..884ae744c878 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1,6 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; import { Clock3Icon, + CopyIcon, + InboxIcon, MoreHorizontalIcon, PencilIcon, PlayIcon, @@ -17,6 +19,8 @@ import type { ScheduledTaskId, ScheduledTaskSchedule, ScheduledTaskUpsertInput, + ScheduledTaskWebhookDeliveryOutcome, + ScheduledTaskWebhookDeliverySummary, ThreadId, } from "@t3tools/contracts"; import { @@ -37,7 +41,14 @@ import { deriveProviderInstanceEntries, sortProviderInstanceEntries, } from "../../providerInstances"; -import { useEnvironment, type EnvironmentPresentation } from "../../state/environments"; +import { requestConfirmDialog } from "../../confirmDialog"; +import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { + useEnvironment, + useEnvironmentHttpBaseUrl, + type EnvironmentPresentation, +} from "../../state/environments"; import { useProjects } from "../../state/entities"; import { useEnvironmentQuery } from "../../state/query"; import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; @@ -46,10 +57,14 @@ import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { useSettingsScope } from "./SettingsScopeContext"; import { + DEFAULT_WEBHOOK_PROMPT, + WEBHOOK_SIGNATURE_DEFAULTS, matchesScheduledTaskScope, + scheduleFromDraft, scheduledTaskDefaultModel, taskToDraft, type DraftState, + type ScheduleMode, type WorkspaceMode, } from "./scheduledTasksSettings.logic"; import { Label } from "../ui/label"; @@ -112,6 +127,9 @@ const EMPTY_DRAFT: DraftState = { runtimeMode: "full-access", interactionMode: "default", baseModelSelection: null, + signatureEnabled: false, + ...WEBHOOK_SIGNATURE_DEFAULTS, + signatureSecret: "", }; /** Labelled field: a caption sitting above its control. */ @@ -148,19 +166,6 @@ function splitModelKey(value: string): ModelSelection | null { }; } -function scheduleFromDraft(draft: DraftState): ScheduledTaskSchedule { - if (draft.scheduleMode === "interval") { - const everyMs = Math.round(Number(draft.intervalMinutes) * 60_000); - return { type: "interval", everyMs }; - } - const selectedEveryDay = draft.weekdays.size === 0 || draft.weekdays.size === 7; - return { - type: "fixed_time", - timeOfDay: draft.timeOfDay || "09:00", - ...(selectedEveryDay ? {} : { weekdays: [...draft.weekdays].toSorted() }), - }; -} - export function scheduleLabel(schedule: ScheduledTaskSchedule): string { if (schedule.type === "webhook") return "On webhook"; if (schedule.type === "interval") { @@ -200,6 +205,20 @@ export function relativeLabel(value: string | null): string { return `in ${Math.round(hours / 24)}d`; } +const DELIVERY_OUTCOME_LABELS: Record = { + accepted: "Ran", + dispatch_failed: "Run failed", + rejected_signature: "Bad signature", + disabled: "Task paused", + rate_limited: "Rate limited", +}; + +function deliveryOutcomeVariant(outcome: ScheduledTaskWebhookDeliveryOutcome) { + if (outcome === "accepted") return "success"; + if (outcome === "disabled" || outcome === "rate_limited") return "warning"; + return "error"; +} + function statusVariant(status: ScheduledTask["lastRunStatus"]) { if (status === "failed") return "error"; if (status === "succeeded") return "success"; @@ -313,7 +332,7 @@ function ScheduledTaskEnvironmentSection({ const linkedTask = tasks?.find((task) => task.id === taskId); const openedLink = useRef(false); useEffect(() => { - if (!openedLink.current && linkedTask && linkedTask.schedule.type !== "webhook") { + if (!openedLink.current && linkedTask) { openedLink.current = true; onEdit(environment.environmentId, linkedTask); } @@ -347,12 +366,6 @@ function ScheduledTaskEnvironmentSection({ description="This task no longer exists or is outside the selected project scope." role="status" /> - ) : linkedTask?.schedule.type === "webhook" ? ( - ) : null} {tasks.length === 0 ? ( void; }) { const [busy, setBusy] = useState(false); + const [deliveriesOpen, setDeliveriesOpen] = useState(false); + const isWebhook = task.schedule.type === "webhook"; const toggle = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, { label: "scheduled task enabled", }); @@ -422,11 +437,13 @@ function ScheduledTaskRow({
{scheduleLabel(task.schedule)} ·{" "} - {task.enabled - ? task.nextRunAt - ? `Next run ${relativeLabel(task.nextRunAt)}` - : "Not scheduled" - : "Paused"} + {!task.enabled + ? "Paused" + : isWebhook + ? "Listening" + : task.nextRunAt + ? `Next run ${relativeLabel(task.nextRunAt)}` + : "Not scheduled"} {task.lastRunStatus !== "never" ? ( {task.lastRunStatus} @@ -456,15 +473,21 @@ function ScheduledTaskRow({ - {/* Webhook tasks are not editable here yet; saving would drop their URL. */} - + Edit - void act("run")} disabled={task.schedule.type === "webhook"}> - - Run now - + {isWebhook ? ( + setDeliveriesOpen(true)}> + + Deliveries + + ) : ( + void act("run")}> + + Run now + + )} void act("delete")}> @@ -472,12 +495,240 @@ function ScheduledTaskRow({ + {deliveriesOpen ? ( + setDeliveriesOpen(false)} + /> + ) : null}
} /> ); } +function WebhookDeliveriesDialog({ + environmentId, + task, + onClose, +}: { + readonly environmentId: EnvironmentId; + readonly task: ScheduledTask; + readonly onClose: () => void; +}) { + const deliveriesQuery = useEnvironmentQuery( + serverEnvironment.scheduledTaskWebhookDeliveries({ environmentId, input: { id: task.id } }), + ); + const [selectedId, setSelectedId] = useState( + null, + ); + const selectedQuery = useEnvironmentQuery( + selectedId === null + ? null + : serverEnvironment.scheduledTaskWebhookDelivery({ + environmentId, + input: { id: task.id, deliveryId: selectedId }, + }), + ); + const deliveries = deliveriesQuery.data?.deliveries ?? null; + const selected = selectedQuery.data?.delivery ?? null; + const error = selectedId === null ? deliveriesQuery.error : selectedQuery.error; + + return ( + { + if (!next) onClose(); + }} + > + + + Deliveries · {task.title} + Recent requests to this task's webhook URL. + + + {error ?

{error}

: null} + {selectedId !== null && selected === null && selectedQuery.error === null ? ( +

+ Loading delivery… +

+ ) : selected ? ( +
+
+ + {DELIVERY_OUTCOME_LABELS[selected.outcome]} + + + {selected.method} · {relativeLabel(selected.receivedAt)} + + {selected.signatureVerified ? ( + Signature verified + ) : null} +
+ {selected.error ?

{selected.error}

: null} + + {selected.renderedPrompt ?? "No run was started for this request."} + + {selected.missingFields.length > 0 ? ( +

+ Empty placeholders: {selected.missingFields.join(", ")} +

+ ) : null} + + {Object.entries(selected.headers) + .map(([name, value]) => `${name}: ${value}`) + .join("\n")} + + {selected.query ? ( + {selected.query} + ) : null} + + {selected.body || "(empty)"} + +
+ ) : selectedId !== null ? null : deliveries === null ? ( +

+ Loading deliveries… +

+ ) : deliveries.length === 0 ? ( +

No requests yet.

+ ) : ( +
    + {deliveries.map((delivery) => ( +
  • + +
  • + ))} +
+ )} +
+ + {selectedId !== null ? ( + + ) : ( + + )} + }>Done + +
+
+ ); +} + +function DeliveryBlock({ title, children }: { title: string; children: ReactNode }) { + return ( +
+ +
+        {children}
+      
+
+ ); +} + +function WebhookEndpointField({ + environmentId, + task, +}: { + readonly environmentId: EnvironmentId; + readonly task: ScheduledTask | null; +}) { + const httpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "webhook URL" }); + const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, { + label: "scheduled task rotate webhook token", + }); + const [rotating, setRotating] = useState(false); + const endpoint = task?.webhook; + if (!task || !endpoint) { + return

The URL appears after you save.

; + } + const { address: url, copyable, note } = webhookAddress(endpoint, httpBaseUrl); + const rotateUrl = async () => { + const confirmed = + (await requestConfirmDialog("Rotate this webhook URL?\nThe current URL stops working.", { + variant: "destructive", + })) ?? + // No themed dialog host is mounted; fall back to the native prompt + // rather than rotating unasked. + window.confirm("Rotate this webhook URL? The current URL stops working."); + if (!confirmed) return; + setRotating(true); + const result = await rotate({ environmentId, input: { id: task.id } }); + setRotating(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not rotate webhook URL", + description: String(squashAtomCommandFailure(result)), + }), + ); + } + }; + return ( +
+
+ event.currentTarget.select()} + /> + + +
+ {note !== null ?

{note}

: null} +
+ ); +} + function ScheduledTaskEditorDialog({ initialEnvironmentId, task, @@ -527,6 +778,11 @@ function ScheduledTaskEditorDialog({ draft.editingId !== null && tasksQuery.data !== null && !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId); + // The live row, so a rotated URL shows up without reopening the dialog. + // Once the list has loaded, a missing task is gone; don't keep showing its URL. + const liveTask = tasksQuery.data + ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null) + : task; const selectedProjectId = draft.projectId || projects[0]?.id || ""; const selectedProject = projects.find((project) => project.id === selectedProjectId); @@ -574,6 +830,16 @@ function ScheduledTaskEditorDialog({ return; } const schedule = scheduleFromDraft(draft); + if ( + schedule.type === "webhook" && + schedule.signature && + (!schedule.signature.header || + (!schedule.signature.secret && + !(liveTask?.schedule.type === "webhook" && liveTask.webhook?.hasSecret))) + ) { + reportFailure("Signing secret is required", "Enter the signature header and secret."); + return; + } if ( schedule.type === "interval" && (!Number.isSafeInteger(schedule.everyMs) || schedule.everyMs < MIN_SCHEDULED_TASK_INTERVAL_MS) @@ -643,7 +909,8 @@ function ScheduledTaskEditorDialog({ {draft.editingId ? "Edit task" : "New task"} - Run a prompt automatically — on an interval or at a fixed time. + Run a prompt automatically — on an interval, at a fixed time, or when a webhook is + called. @@ -833,17 +1100,116 @@ function ScheduledTaskEditorDialog({ aria-label="Schedule type" value={[draft.scheduleMode]} onValueChange={(values) => { - const mode = values[0]; - if (mode === "fixed" || mode === "interval") - setDraft((current) => ({ ...current, scheduleMode: mode })); + const mode = values[0] as ScheduleMode | undefined; + if (mode === "fixed" || mode === "interval" || mode === "webhook") + setDraft((current) => ({ + ...current, + scheduleMode: mode, + prompt: + mode === "webhook" && !current.prompt.trim() + ? DEFAULT_WEBHOOK_PROMPT + : current.prompt, + })); }} > At a time Every interval + On webhook - {draft.scheduleMode === "fixed" ? ( + {draft.scheduleMode === "webhook" ? ( +
+ +

+ { + "Each request runs the prompt. Use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}} or {{request}} in the prompt; only what it names reaches the agent." + } +

+
+
+ +

+ Reject requests without a valid HMAC-SHA256 signature of the body. +

+
+ + setDraft((current) => ({ ...current, signatureEnabled })) + } + /> +
+ {draft.signatureEnabled ? ( +
+ + + setDraft((current) => ({ + ...current, + signatureHeader: event.target.value, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + signaturePrefix: event.target.value, + })) + } + /> + + + + + + + setDraft((current) => ({ + ...current, + signatureSecret: event.target.value, + })) + } + /> + +
+ ) : null} +
+ ) : draft.scheduleMode === "fixed" ? (
diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index 51b5576de607..9ccd9881d943 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -19,6 +19,7 @@ import { deriveProviderInstanceEntries } from "../../providerInstances"; import { scheduledTaskDefaultModel, matchesScheduledTaskScope, + scheduleFromDraft, taskToDraft, } from "./scheduledTasksSettings.logic"; @@ -168,6 +169,33 @@ describe("editing scheduled task branch settings", () => { }); }); +describe("webhook scheduled tasks", () => { + const signature = { header: "x-signature", encoding: "base64", prefix: "" } as const; + const webhookTask: ScheduledTask = { + ...legacyTask, + schedule: { type: "webhook", signature }, + webhook: { path: "/api/hooks/legacy-task/token", url: null, hasSecret: true }, + }; + + it("keeps a stored secret when the secret field is left blank", () => { + const draft = taskToDraft(webhookTask); + expect(draft.scheduleMode).toBe("webhook"); + expect(draft.signatureSecret).toBe(""); + expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature }); + expect(scheduleFromDraft({ ...draft, signatureSecret: " new " })).toEqual({ + type: "webhook", + signature: { ...signature, secret: "new" }, + }); + }); + + it("drops the signature when it is switched off and offers GitHub's settings", () => { + const draft = taskToDraft({ ...webhookTask, schedule: { type: "webhook", signature: null } }); + expect(draft.signatureEnabled).toBe(false); + expect(draft.signatureHeader).toBe("x-hub-signature-256"); + expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature: null }); + }); +}); + describe("scheduled task model defaults", () => { const instanceId = ProviderInstanceId.make("codex"); const projectId = ProjectId.make("project"); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index fcdb186ed8d8..c6d0cb28b940 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -3,6 +3,7 @@ import { type ProjectId, ScheduledTaskId, type ScheduledTask, + type ScheduledTaskUpsertSchedule, type ModelSelection, type RuntimeMode, type ProviderInteractionMode, @@ -43,7 +44,7 @@ export function validateScheduledTasksSearch(raw: Record) { }; } -type ScheduleMode = "fixed" | "interval"; +export type ScheduleMode = "fixed" | "interval" | "webhook"; export type WorkspaceMode = "root" | "worktree" | "existing_worktree"; export interface DraftState { @@ -71,6 +72,49 @@ export interface DraftState { * (reasoning, temperature, …) when the model itself is left unchanged. */ readonly baseModelSelection: ModelSelection | null; + readonly signatureEnabled: boolean; + readonly signatureHeader: string; + readonly signatureEncoding: "hex" | "base64"; + readonly signaturePrefix: string; + /** Write-only: empty keeps the secret already stored on the server. */ + readonly signatureSecret: string; +} + +/** GitHub's signature settings, the most common sender. */ +export const WEBHOOK_SIGNATURE_DEFAULTS = { + signatureHeader: "x-hub-signature-256", + signatureEncoding: "hex", + signaturePrefix: "sha256=", +} as const; + +/** Prompt a new webhook task starts with: the whole request, which the user can narrow down. */ +export const DEFAULT_WEBHOOK_PROMPT = "Handle this webhook:\n{{request}}"; + +export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedule { + if (draft.scheduleMode === "webhook") { + const secret = draft.signatureSecret.trim(); + return { + type: "webhook", + signature: draft.signatureEnabled + ? { + header: draft.signatureHeader.trim(), + encoding: draft.signatureEncoding, + prefix: draft.signaturePrefix, + ...(secret ? { secret } : {}), + } + : null, + }; + } + if (draft.scheduleMode === "interval") { + const everyMs = Math.round(Number(draft.intervalMinutes) * 60_000); + return { type: "interval", everyMs }; + } + const selectedEveryDay = draft.weekdays.size === 0 || draft.weekdays.size === 7; + return { + type: "fixed_time", + timeOfDay: draft.timeOfDay || "09:00", + ...(selectedEveryDay ? {} : { weekdays: [...draft.weekdays].toSorted() }), + }; } export function taskToDraft(task: ScheduledTask): DraftState { @@ -84,7 +128,8 @@ export function taskToDraft(task: ScheduledTask): DraftState { title: task.title, prompt: task.prompt, enabled: task.enabled, - scheduleMode: schedule.type === "interval" ? "interval" : "fixed", + scheduleMode: + schedule.type === "interval" ? "interval" : schedule.type === "webhook" ? "webhook" : "fixed", intervalMinutes: schedule.type === "interval" ? String(Math.max(1, schedule.everyMs / 60_000)) : "15", timeOfDay: schedule.type === "fixed_time" ? schedule.timeOfDay : "09:00", @@ -105,6 +150,15 @@ export function taskToDraft(task: ScheduledTask): DraftState { runtimeMode: task.runtimeMode, interactionMode: task.interactionMode, baseModelSelection: task.modelSelection, + ...(schedule.type === "webhook" && schedule.signature !== null + ? { + signatureEnabled: true, + signatureHeader: schedule.signature.header, + signatureEncoding: schedule.signature.encoding, + signaturePrefix: schedule.signature.prefix, + } + : { signatureEnabled: false, ...WEBHOOK_SIGNATURE_DEFAULTS }), + signatureSecret: "", }; } diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index bb9af1ff358d..bf30f0ba637a 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -63,6 +63,31 @@ environment's time zone, which may differ from your phone's. You can edit, pause, resume, run immediately, or delete a task from the list. Leaving an edited form asks before discarding unsaved changes. +## Webhook automations + +In **Settings → Scheduled tasks**, choose **On webhook** as a task's schedule to run it whenever another service +calls its URL, such as GitHub on a new pull request or a CI job that failed. +After you save the task, copy its URL from the editor. If the environment uses a +[T3 Connect](remote-access.md) managed tunnel, the URL is public; otherwise it +works anywhere the environment itself is reachable. **Rotate** replaces the URL and +the old one stops working. + +The prompt decides what the agent sees. Placeholders pull values out of the +request: `{{body.path}}` for a JSON or form field, `{{headers.name}}`, +`{{query.name}}`, `{{body}}` for the raw body, and `{{request}}` for everything. +For example, `Review this PR: {{body.pull_request.html_url}}` sends only the +pull request link. A placeholder with no value is left empty. + +For GitHub, turn on **Require signature**, keep the header +`x-hub-signature-256`, hex encoding and the `sha256=` prefix, and enter the +same secret in the repository's webhook settings with content type +`application/json`. Requests without a valid signature are rejected. + +On desktop and web, pick **Deliveries** from a task's menu to see recent +requests and the prompt each one produced. If the environment is offline, the sender gets an error and +nothing runs; redeliver from the sender, such as GitHub's **Recent Deliveries**, +once it is back. + ## Defaults and inheritance General contains the model and workspace for new threads. Integrations controls agent browser diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 86f947894827..1e42b6826b3f 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1076,6 +1076,14 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, }), + scheduledTaskWebhookDeliveries: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:scheduled-task:webhook-deliveries", + tag: WS_METHODS.scheduledTasksListWebhookDeliveries, + }), + scheduledTaskWebhookDelivery: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:scheduled-task:webhook-delivery", + tag: WS_METHODS.scheduledTasksGetWebhookDelivery, + }), /** Live scheduled-task list: snapshot on subscribe, fresh list after every server-side change. */ scheduledTasksLive: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:scheduled-tasks:live", @@ -1291,14 +1299,6 @@ export function createServerEnvironmentAtoms( scheduler: configScheduler, concurrency: configConcurrency, }), - listScheduledTaskWebhookDeliveries: createEnvironmentRpcCommand(runtime, { - label: "environment-data:server:scheduled-task:list-webhook-deliveries", - tag: WS_METHODS.scheduledTasksListWebhookDeliveries, - }), - getScheduledTaskWebhookDelivery: createEnvironmentRpcCommand(runtime, { - label: "environment-data:server:scheduled-task:get-webhook-delivery", - tag: WS_METHODS.scheduledTasksGetWebhookDelivery, - }), refreshUsageRates: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-usage-rates", tag: WS_METHODS.serverRefreshUsageRates,