authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 15:14:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 15:14:52-07:00
logea4b44343d3c8f71cf49a72a266d00eac7389435
treeb9d2b04f9abfb18437983d717f7706148e851b20
parentaee889b0e043fdc652e48850698d08ebc563d5bc
signature Signed by PGP key B5690EEEBB952194

feat(web): create webhook automations and inspect their deliveries (#15088)


5 files changed, 520 insertions(+), 47 deletions(-)

apps/web/src/components/settings/ScheduledTasksSettings.tsx+403-37
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 Clock3Icon,3 Clock3Icon,
4 CopyIcon,
5 InboxIcon,
4 MoreHorizontalIcon,6 MoreHorizontalIcon,
5 PencilIcon,7 PencilIcon,
6 PlayIcon,8 PlayIcon,
...@@ -17,6 +19,8 @@ import type {...@@ -17,6 +19,8 @@ import type {
17 ScheduledTaskId,19 ScheduledTaskId,
18 ScheduledTaskSchedule,20 ScheduledTaskSchedule,
19 ScheduledTaskUpsertInput,21 ScheduledTaskUpsertInput,
22 ScheduledTaskWebhookDeliveryOutcome,
23 ScheduledTaskWebhookDeliverySummary,
20 ThreadId,24 ThreadId,
21} from "@t3tools/contracts";25} from "@t3tools/contracts";
22import {26import {
...@@ -37,7 +41,14 @@ import {...@@ -37,7 +41,14 @@ import {
37 deriveProviderInstanceEntries,41 deriveProviderInstanceEntries,
38 sortProviderInstanceEntries,42 sortProviderInstanceEntries,
39} from "../../providerInstances";43} from "../../providerInstances";
40import { useEnvironment, type EnvironmentPresentation } from "../../state/environments";44import { requestConfirmDialog } from "../../confirmDialog";
45import { webhookAddress } from "@t3tools/client-runtime/webhook-address";
46import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
47import {
48 useEnvironment,
49 useEnvironmentHttpBaseUrl,
50 type EnvironmentPresentation,
51} from "../../state/environments";
41import { useProjects } from "../../state/entities";52import { useProjects } from "../../state/entities";
42import { useEnvironmentQuery } from "../../state/query";53import { useEnvironmentQuery } from "../../state/query";
43import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";54import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
...@@ -46,10 +57,14 @@ import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker";...@@ -46,10 +57,14 @@ import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker";
46import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";57import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
47import { useSettingsScope } from "./SettingsScopeContext";58import { useSettingsScope } from "./SettingsScopeContext";
48import {59import {
60 DEFAULT_WEBHOOK_PROMPT,
61 WEBHOOK_SIGNATURE_DEFAULTS,
49 matchesScheduledTaskScope,62 matchesScheduledTaskScope,
63 scheduleFromDraft,
50 scheduledTaskDefaultModel,64 scheduledTaskDefaultModel,
51 taskToDraft,65 taskToDraft,
52 type DraftState,66 type DraftState,
67 type ScheduleMode,
53 type WorkspaceMode,68 type WorkspaceMode,
54} from "./scheduledTasksSettings.logic";69} from "./scheduledTasksSettings.logic";
55import { Label } from "../ui/label";70import { Label } from "../ui/label";
...@@ -112,6 +127,9 @@ const EMPTY_DRAFT: DraftState = {...@@ -112,6 +127,9 @@ const EMPTY_DRAFT: DraftState = {
112 runtimeMode: "full-access",127 runtimeMode: "full-access",
113 interactionMode: "default",128 interactionMode: "default",
114 baseModelSelection: null,129 baseModelSelection: null,
130 signatureEnabled: false,
131 ...WEBHOOK_SIGNATURE_DEFAULTS,
132 signatureSecret: "",
115};133};
116134
117/** Labelled field: a caption sitting above its control. */135/** Labelled field: a caption sitting above its control. */
...@@ -148,19 +166,6 @@ function splitModelKey(value: string): ModelSelection | null {...@@ -148,19 +166,6 @@ function splitModelKey(value: string): ModelSelection | null {
148 };166 };
149}167}
150168
151function scheduleFromDraft(draft: DraftState): ScheduledTaskSchedule {
152 if (draft.scheduleMode === "interval") {
153 const everyMs = Math.round(Number(draft.intervalMinutes) * 60_000);
154 return { type: "interval", everyMs };
155 }
156 const selectedEveryDay = draft.weekdays.size === 0 || draft.weekdays.size === 7;
157 return {
158 type: "fixed_time",
159 timeOfDay: draft.timeOfDay || "09:00",
160 ...(selectedEveryDay ? {} : { weekdays: [...draft.weekdays].toSorted() }),
161 };
162}
163
164export function scheduleLabel(schedule: ScheduledTaskSchedule): string {169export function scheduleLabel(schedule: ScheduledTaskSchedule): string {
165 if (schedule.type === "webhook") return "On webhook";170 if (schedule.type === "webhook") return "On webhook";
166 if (schedule.type === "interval") {171 if (schedule.type === "interval") {
...@@ -200,6 +205,20 @@ export function relativeLabel(value: string | null): string {...@@ -200,6 +205,20 @@ export function relativeLabel(value: string | null): string {
200 return `in ${Math.round(hours / 24)}d`;205 return `in ${Math.round(hours / 24)}d`;
201}206}
202207
208const DELIVERY_OUTCOME_LABELS: Record<ScheduledTaskWebhookDeliveryOutcome, string> = {
209 accepted: "Ran",
210 dispatch_failed: "Run failed",
211 rejected_signature: "Bad signature",
212 disabled: "Task paused",
213 rate_limited: "Rate limited",
214};
215
216function deliveryOutcomeVariant(outcome: ScheduledTaskWebhookDeliveryOutcome) {
217 if (outcome === "accepted") return "success";
218 if (outcome === "disabled" || outcome === "rate_limited") return "warning";
219 return "error";
220}
221
203function statusVariant(status: ScheduledTask["lastRunStatus"]) {222function statusVariant(status: ScheduledTask["lastRunStatus"]) {
204 if (status === "failed") return "error";223 if (status === "failed") return "error";
205 if (status === "succeeded") return "success";224 if (status === "succeeded") return "success";
...@@ -313,7 +332,7 @@ function ScheduledTaskEnvironmentSection({...@@ -313,7 +332,7 @@ function ScheduledTaskEnvironmentSection({
313 const linkedTask = tasks?.find((task) => task.id === taskId);332 const linkedTask = tasks?.find((task) => task.id === taskId);
314 const openedLink = useRef(false);333 const openedLink = useRef(false);
315 useEffect(() => {334 useEffect(() => {
316 if (!openedLink.current && linkedTask && linkedTask.schedule.type !== "webhook") {335 if (!openedLink.current && linkedTask) {
317 openedLink.current = true;336 openedLink.current = true;
318 onEdit(environment.environmentId, linkedTask);337 onEdit(environment.environmentId, linkedTask);
319 }338 }
...@@ -347,12 +366,6 @@ function ScheduledTaskEnvironmentSection({...@@ -347,12 +366,6 @@ function ScheduledTaskEnvironmentSection({
347 description="This task no longer exists or is outside the selected project scope."366 description="This task no longer exists or is outside the selected project scope."
348 role="status"367 role="status"
349 />368 />
350 ) : linkedTask?.schedule.type === "webhook" ? (
351 <SettingsRow
352 title="Webhook tasks can't be edited here yet"
353 description="Update T3 Code to edit this task."
354 role="status"
355 />
356 ) : null}369 ) : null}
357 {tasks.length === 0 ? (370 {tasks.length === 0 ? (
358 <SettingsRow371 <SettingsRow
...@@ -385,6 +398,8 @@ function ScheduledTaskRow({...@@ -385,6 +398,8 @@ function ScheduledTaskRow({
385 readonly onEdit: () => void;398 readonly onEdit: () => void;
386}) {399}) {
387 const [busy, setBusy] = useState(false);400 const [busy, setBusy] = useState(false);
401 const [deliveriesOpen, setDeliveriesOpen] = useState(false);
402 const isWebhook = task.schedule.type === "webhook";
388 const toggle = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, {403 const toggle = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, {
389 label: "scheduled task enabled",404 label: "scheduled task enabled",
390 });405 });
...@@ -422,11 +437,13 @@ function ScheduledTaskRow({...@@ -422,11 +437,13 @@ function ScheduledTaskRow({
422 <div className="flex flex-wrap items-center gap-2">437 <div className="flex flex-wrap items-center gap-2">
423 <span>438 <span>
424 {scheduleLabel(task.schedule)} ·{" "}439 {scheduleLabel(task.schedule)} ·{" "}
425 {task.enabled440 {!task.enabled
426 ? task.nextRunAt441 ? "Paused"
427 ? `Next run ${relativeLabel(task.nextRunAt)}`442 : isWebhook
428 : "Not scheduled"443 ? "Listening"
429 : "Paused"}444 : task.nextRunAt
445 ? `Next run ${relativeLabel(task.nextRunAt)}`
446 : "Not scheduled"}
430 </span>447 </span>
431 {task.lastRunStatus !== "never" ? (448 {task.lastRunStatus !== "never" ? (
432 <Badge variant={statusVariant(task.lastRunStatus)}>{task.lastRunStatus}</Badge>449 <Badge variant={statusVariant(task.lastRunStatus)}>{task.lastRunStatus}</Badge>
...@@ -456,15 +473,21 @@ function ScheduledTaskRow({...@@ -456,15 +473,21 @@ function ScheduledTaskRow({
456 <MoreHorizontalIcon className="size-4" />473 <MoreHorizontalIcon className="size-4" />
457 </MenuTrigger>474 </MenuTrigger>
458 <MenuPopup align="end">475 <MenuPopup align="end">
459 {/* Webhook tasks are not editable here yet; saving would drop their URL. */}476 <MenuItem onClick={onEdit}>
460 <MenuItem onClick={onEdit} disabled={task.schedule.type === "webhook"}>
461 <PencilIcon />477 <PencilIcon />
462 Edit478 Edit
463 </MenuItem>479 </MenuItem>
464 <MenuItem onClick={() => void act("run")} disabled={task.schedule.type === "webhook"}>480 {isWebhook ? (
465 <PlayIcon />481 <MenuItem onClick={() => setDeliveriesOpen(true)}>
466 Run now482 <InboxIcon />
467 </MenuItem>483 Deliveries
484 </MenuItem>
485 ) : (
486 <MenuItem onClick={() => void act("run")}>
487 <PlayIcon />
488 Run now
489 </MenuItem>
490 )}
468 <MenuSeparator />491 <MenuSeparator />
469 <MenuItem onClick={() => void act("delete")}>492 <MenuItem onClick={() => void act("delete")}>
470 <Trash2Icon />493 <Trash2Icon />
...@@ -472,12 +495,240 @@ function ScheduledTaskRow({...@@ -472,12 +495,240 @@ function ScheduledTaskRow({
472 </MenuItem>495 </MenuItem>
473 </MenuPopup>496 </MenuPopup>
474 </Menu>497 </Menu>
498 {deliveriesOpen ? (
499 <WebhookDeliveriesDialog
500 environmentId={environmentId}
501 task={task}
502 onClose={() => setDeliveriesOpen(false)}
503 />
504 ) : null}
475 </div>505 </div>
476 }506 }
477 />507 />
478 );508 );
479}509}
480510
511function WebhookDeliveriesDialog({
512 environmentId,
513 task,
514 onClose,
515}: {
516 readonly environmentId: EnvironmentId;
517 readonly task: ScheduledTask;
518 readonly onClose: () => void;
519}) {
520 const deliveriesQuery = useEnvironmentQuery(
521 serverEnvironment.scheduledTaskWebhookDeliveries({ environmentId, input: { id: task.id } }),
522 );
523 const [selectedId, setSelectedId] = useState<ScheduledTaskWebhookDeliverySummary["id"] | null>(
524 null,
525 );
526 const selectedQuery = useEnvironmentQuery(
527 selectedId === null
528 ? null
529 : serverEnvironment.scheduledTaskWebhookDelivery({
530 environmentId,
531 input: { id: task.id, deliveryId: selectedId },
532 }),
533 );
534 const deliveries = deliveriesQuery.data?.deliveries ?? null;
535 const selected = selectedQuery.data?.delivery ?? null;
536 const error = selectedId === null ? deliveriesQuery.error : selectedQuery.error;
537
538 return (
539 <Dialog
540 open
541 onOpenChange={(next) => {
542 if (!next) onClose();
543 }}
544 >
545 <DialogPopup className="max-w-2xl">
546 <DialogHeader>
547 <DialogTitle>Deliveries · {task.title}</DialogTitle>
548 <DialogDescription>Recent requests to this task's webhook URL.</DialogDescription>
549 </DialogHeader>
550 <DialogPanel>
551 {error ? <p className="text-sm text-destructive">{error}</p> : null}
552 {selectedId !== null && selected === null && selectedQuery.error === null ? (
553 <p className="text-sm text-muted-foreground" role="status">
554 Loading delivery…
555 </p>
556 ) : selected ? (
557 <div className="space-y-4">
558 <div className="flex flex-wrap items-center gap-2 text-sm">
559 <Badge variant={deliveryOutcomeVariant(selected.outcome)}>
560 {DELIVERY_OUTCOME_LABELS[selected.outcome]}
561 </Badge>
562 <span>
563 {selected.method} · {relativeLabel(selected.receivedAt)}
564 </span>
565 {selected.signatureVerified ? (
566 <span className="text-muted-foreground">Signature verified</span>
567 ) : null}
568 </div>
569 {selected.error ? <p className="text-sm text-destructive">{selected.error}</p> : null}
570 <DeliveryBlock title="Prompt sent to the agent">
571 {selected.renderedPrompt ?? "No run was started for this request."}
572 </DeliveryBlock>
573 {selected.missingFields.length > 0 ? (
574 <p className="text-sm text-muted-foreground">
575 Empty placeholders: {selected.missingFields.join(", ")}
576 </p>
577 ) : null}
578 <DeliveryBlock title="Headers">
579 {Object.entries(selected.headers)
580 .map(([name, value]) => `${name}: ${value}`)
581 .join("\n")}
582 </DeliveryBlock>
583 {selected.query ? (
584 <DeliveryBlock title="Query">{selected.query}</DeliveryBlock>
585 ) : null}
586 <DeliveryBlock title={selected.bodyTruncated ? "Body (truncated)" : "Body"}>
587 {selected.body || "(empty)"}
588 </DeliveryBlock>
589 </div>
590 ) : selectedId !== null ? null : deliveries === null ? (
591 <p className="text-sm text-muted-foreground" role="status">
592 Loading deliveries…
593 </p>
594 ) : deliveries.length === 0 ? (
595 <p className="text-sm text-muted-foreground">No requests yet.</p>
596 ) : (
597 <ul className="divide-y divide-border">
598 {deliveries.map((delivery) => (
599 <li key={delivery.id}>
600 <button
601 type="button"
602 className="flex w-full flex-wrap items-center gap-2 py-2 text-left text-sm hover:bg-accent/40"
603 onClick={() => setSelectedId(delivery.id)}
604 >
605 <Badge variant={deliveryOutcomeVariant(delivery.outcome)}>
606 {DELIVERY_OUTCOME_LABELS[delivery.outcome]}
607 </Badge>
608 <span>{delivery.method}</span>
609 <span className="text-muted-foreground">
610 {relativeLabel(delivery.receivedAt)}
611 </span>
612 {delivery.missingFields.length > 0 ? (
613 <span className="text-muted-foreground">
614 {delivery.missingFields.length} empty placeholder
615 {delivery.missingFields.length === 1 ? "" : "s"}
616 </span>
617 ) : null}
618 </button>
619 </li>
620 ))}
621 </ul>
622 )}
623 </DialogPanel>
624 <DialogFooter>
625 {selectedId !== null ? (
626 <Button variant="outline" size="sm" onClick={() => setSelectedId(null)}>
627 Back
628 </Button>
629 ) : (
630 <Button
631 variant="outline"
632 size="sm"
633 disabled={deliveriesQuery.isPending}
634 onClick={deliveriesQuery.refresh}
635 >
636 Refresh
637 </Button>
638 )}
639 <DialogClose render={<Button size="sm" />}>Done</DialogClose>
640 </DialogFooter>
641 </DialogPopup>
642 </Dialog>
643 );
644}
645
646function DeliveryBlock({ title, children }: { title: string; children: ReactNode }) {
647 return (
648 <div className="space-y-1">
649 <Label>{title}</Label>
650 <pre className="max-h-64 overflow-auto rounded-md bg-muted p-2 text-xs whitespace-pre-wrap break-all">
651 {children}
652 </pre>
653 </div>
654 );
655}
656
657function WebhookEndpointField({
658 environmentId,
659 task,
660}: {
661 readonly environmentId: EnvironmentId;
662 readonly task: ScheduledTask | null;
663}) {
664 const httpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
665 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "webhook URL" });
666 const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, {
667 label: "scheduled task rotate webhook token",
668 });
669 const [rotating, setRotating] = useState(false);
670 const endpoint = task?.webhook;
671 if (!task || !endpoint) {
672 return <p className="text-sm text-muted-foreground">The URL appears after you save.</p>;
673 }
674 const { address: url, copyable, note } = webhookAddress(endpoint, httpBaseUrl);
675 const rotateUrl = async () => {
676 const confirmed =
677 (await requestConfirmDialog("Rotate this webhook URL?\nThe current URL stops working.", {
678 variant: "destructive",
679 })) ??
680 // No themed dialog host is mounted; fall back to the native prompt
681 // rather than rotating unasked.
682 window.confirm("Rotate this webhook URL? The current URL stops working.");
683 if (!confirmed) return;
684 setRotating(true);
685 const result = await rotate({ environmentId, input: { id: task.id } });
686 setRotating(false);
687 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
688 toastManager.add(
689 stackedThreadToast({
690 type: "error",
691 title: "Could not rotate webhook URL",
692 description: String(squashAtomCommandFailure(result)),
693 }),
694 );
695 }
696 };
697 return (
698 <div className="space-y-1.5">
699 <div className="flex items-center gap-2">
700 <Input
701 readOnly
702 aria-label="Webhook URL"
703 value={url}
704 onFocus={(event) => event.currentTarget.select()}
705 />
706 <Button
707 size="sm"
708 variant="outline"
709 type="button"
710 // A bare path is not a URL a sender can call.
711 disabled={!copyable}
712 onClick={() => copyToClipboard(url, undefined)}
713 >
714 <CopyIcon />
715 {isCopied ? "Copied" : "Copy"}
716 </Button>
717 <Button
718 size="sm"
719 variant="outline"
720 type="button"
721 disabled={rotating}
722 onClick={() => void rotateUrl()}
723 >
724 Rotate
725 </Button>
726 </div>
727 {note !== null ? <p className="text-xs text-muted-foreground">{note}</p> : null}
728 </div>
729 );
730}
731
481function ScheduledTaskEditorDialog({732function ScheduledTaskEditorDialog({
482 initialEnvironmentId,733 initialEnvironmentId,
483 task,734 task,
...@@ -527,6 +778,11 @@ function ScheduledTaskEditorDialog({...@@ -527,6 +778,11 @@ function ScheduledTaskEditorDialog({
527 draft.editingId !== null &&778 draft.editingId !== null &&
528 tasksQuery.data !== null &&779 tasksQuery.data !== null &&
529 !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId);780 !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId);
781 // The live row, so a rotated URL shows up without reopening the dialog.
782 // Once the list has loaded, a missing task is gone; don't keep showing its URL.
783 const liveTask = tasksQuery.data
784 ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null)
785 : task;
530 const selectedProjectId = draft.projectId || projects[0]?.id || "";786 const selectedProjectId = draft.projectId || projects[0]?.id || "";
531 const selectedProject = projects.find((project) => project.id === selectedProjectId);787 const selectedProject = projects.find((project) => project.id === selectedProjectId);
532788
...@@ -574,6 +830,16 @@ function ScheduledTaskEditorDialog({...@@ -574,6 +830,16 @@ function ScheduledTaskEditorDialog({
574 return;830 return;
575 }831 }
576 const schedule = scheduleFromDraft(draft);832 const schedule = scheduleFromDraft(draft);
833 if (
834 schedule.type === "webhook" &&
835 schedule.signature &&
836 (!schedule.signature.header ||
837 (!schedule.signature.secret &&
838 !(liveTask?.schedule.type === "webhook" && liveTask.webhook?.hasSecret)))
839 ) {
840 reportFailure("Signing secret is required", "Enter the signature header and secret.");
841 return;
842 }
577 if (843 if (
578 schedule.type === "interval" &&844 schedule.type === "interval" &&
579 (!Number.isSafeInteger(schedule.everyMs) || schedule.everyMs < MIN_SCHEDULED_TASK_INTERVAL_MS)845 (!Number.isSafeInteger(schedule.everyMs) || schedule.everyMs < MIN_SCHEDULED_TASK_INTERVAL_MS)
...@@ -643,7 +909,8 @@ function ScheduledTaskEditorDialog({...@@ -643,7 +909,8 @@ function ScheduledTaskEditorDialog({
643 <DialogHeader>909 <DialogHeader>
644 <DialogTitle>{draft.editingId ? "Edit task" : "New task"}</DialogTitle>910 <DialogTitle>{draft.editingId ? "Edit task" : "New task"}</DialogTitle>
645 <DialogDescription>911 <DialogDescription>
646 Run a prompt automatically — on an interval or at a fixed time.912 Run a prompt automatically — on an interval, at a fixed time, or when a webhook is
913 called.
647 </DialogDescription>914 </DialogDescription>
648 </DialogHeader>915 </DialogHeader>
649916
...@@ -833,17 +1100,116 @@ function ScheduledTaskEditorDialog({...@@ -833,17 +1100,116 @@ function ScheduledTaskEditorDialog({
833 aria-label="Schedule type"1100 aria-label="Schedule type"
834 value={[draft.scheduleMode]}1101 value={[draft.scheduleMode]}
835 onValueChange={(values) => {1102 onValueChange={(values) => {
836 const mode = values[0];1103 const mode = values[0] as ScheduleMode | undefined;
837 if (mode === "fixed" || mode === "interval")1104 if (mode === "fixed" || mode === "interval" || mode === "webhook")
838 setDraft((current) => ({ ...current, scheduleMode: mode }));1105 setDraft((current) => ({
1106 ...current,
1107 scheduleMode: mode,
1108 prompt:
1109 mode === "webhook" && !current.prompt.trim()
1110 ? DEFAULT_WEBHOOK_PROMPT
1111 : current.prompt,
1112 }));
839 }}1113 }}
840 >1114 >
841 <Toggle value="fixed">At a time</Toggle>1115 <Toggle value="fixed">At a time</Toggle>
842 <Toggle value="interval">Every interval</Toggle>1116 <Toggle value="interval">Every interval</Toggle>
1117 <Toggle value="webhook">On webhook</Toggle>
843 </ToggleGroup>1118 </ToggleGroup>
844 </div>1119 </div>
8451120
846 {draft.scheduleMode === "fixed" ? (1121 {draft.scheduleMode === "webhook" ? (
1122 <div className="space-y-4">
1123 <WebhookEndpointField environmentId={environmentId} task={liveTask} />
1124 <p className="text-xs text-muted-foreground">
1125 {
1126 "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."
1127 }
1128 </p>
1129 <div className="flex items-center justify-between gap-4">
1130 <div className="min-w-0 space-y-1">
1131 <Label htmlFor="scheduled-task-signature">Require signature</Label>
1132 <p className="text-sm text-muted-foreground">
1133 Reject requests without a valid HMAC-SHA256 signature of the body.
1134 </p>
1135 </div>
1136 <Switch
1137 id="scheduled-task-signature"
1138 checked={draft.signatureEnabled}
1139 onCheckedChange={(signatureEnabled) =>
1140 setDraft((current) => ({ ...current, signatureEnabled }))
1141 }
1142 />
1143 </div>
1144 {draft.signatureEnabled ? (
1145 <div className="grid gap-3 sm:grid-cols-2">
1146 <Field label="Header" htmlFor="scheduled-task-signature-header">
1147 <Input
1148 id="scheduled-task-signature-header"
1149 value={draft.signatureHeader}
1150 onChange={(event) =>
1151 setDraft((current) => ({
1152 ...current,
1153 signatureHeader: event.target.value,
1154 }))
1155 }
1156 />
1157 </Field>
1158 <Field label="Prefix" htmlFor="scheduled-task-signature-prefix">
1159 <Input
1160 id="scheduled-task-signature-prefix"
1161 value={draft.signaturePrefix}
1162 placeholder="None"
1163 onChange={(event) =>
1164 setDraft((current) => ({
1165 ...current,
1166 signaturePrefix: event.target.value,
1167 }))
1168 }
1169 />
1170 </Field>
1171 <Field label="Encoding" htmlFor="scheduled-task-signature-encoding">
1172 <Select
1173 value={draft.signatureEncoding}
1174 onValueChange={(value) =>
1175 setDraft((current) => ({
1176 ...current,
1177 signatureEncoding: value === "base64" ? "base64" : "hex",
1178 }))
1179 }
1180 >
1181 <SelectTrigger size="sm" id="scheduled-task-signature-encoding">
1182 <SelectValue>{draft.signatureEncoding}</SelectValue>
1183 </SelectTrigger>
1184 <SelectPopup>
1185 <SelectItem value="hex">hex</SelectItem>
1186 <SelectItem value="base64">base64</SelectItem>
1187 </SelectPopup>
1188 </Select>
1189 </Field>
1190 <Field label="Secret" htmlFor="scheduled-task-signature-secret">
1191 <Input
1192 id="scheduled-task-signature-secret"
1193 type="password"
1194 autoComplete="off"
1195 value={draft.signatureSecret}
1196 placeholder={
1197 liveTask?.schedule.type === "webhook" && liveTask.webhook?.hasSecret
1198 ? "Unchanged"
1199 : "Shared secret"
1200 }
1201 onChange={(event) =>
1202 setDraft((current) => ({
1203 ...current,
1204 signatureSecret: event.target.value,
1205 }))
1206 }
1207 />
1208 </Field>
1209 </div>
1210 ) : null}
1211 </div>
1212 ) : draft.scheduleMode === "fixed" ? (
847 <div className="flex flex-wrap items-center gap-3">1213 <div className="flex flex-wrap items-center gap-3">
848 <div className="flex items-center gap-2">1214 <div className="flex items-center gap-2">
849 <Label htmlFor="scheduled-task-time">Run at</Label>1215 <Label htmlFor="scheduled-task-time">Run at</Label>
apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts+28
...@@ -19,6 +19,7 @@ import { deriveProviderInstanceEntries } from "../../providerInstances";...@@ -19,6 +19,7 @@ import { deriveProviderInstanceEntries } from "../../providerInstances";
19import {19import {
20 scheduledTaskDefaultModel,20 scheduledTaskDefaultModel,
21 matchesScheduledTaskScope,21 matchesScheduledTaskScope,
22 scheduleFromDraft,
22 taskToDraft,23 taskToDraft,
23} from "./scheduledTasksSettings.logic";24} from "./scheduledTasksSettings.logic";
2425
...@@ -168,6 +169,33 @@ describe("editing scheduled task branch settings", () => {...@@ -168,6 +169,33 @@ describe("editing scheduled task branch settings", () => {
168 });169 });
169});170});
170171
172describe("webhook scheduled tasks", () => {
173 const signature = { header: "x-signature", encoding: "base64", prefix: "" } as const;
174 const webhookTask: ScheduledTask = {
175 ...legacyTask,
176 schedule: { type: "webhook", signature },
177 webhook: { path: "/api/hooks/legacy-task/token", url: null, hasSecret: true },
178 };
179
180 it("keeps a stored secret when the secret field is left blank", () => {
181 const draft = taskToDraft(webhookTask);
182 expect(draft.scheduleMode).toBe("webhook");
183 expect(draft.signatureSecret).toBe("");
184 expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature });
185 expect(scheduleFromDraft({ ...draft, signatureSecret: " new " })).toEqual({
186 type: "webhook",
187 signature: { ...signature, secret: "new" },
188 });
189 });
190
191 it("drops the signature when it is switched off and offers GitHub's settings", () => {
192 const draft = taskToDraft({ ...webhookTask, schedule: { type: "webhook", signature: null } });
193 expect(draft.signatureEnabled).toBe(false);
194 expect(draft.signatureHeader).toBe("x-hub-signature-256");
195 expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature: null });
196 });
197});
198
171describe("scheduled task model defaults", () => {199describe("scheduled task model defaults", () => {
172 const instanceId = ProviderInstanceId.make("codex");200 const instanceId = ProviderInstanceId.make("codex");
173 const projectId = ProjectId.make("project");201 const projectId = ProjectId.make("project");
apps/web/src/components/settings/scheduledTasksSettings.logic.ts+56-2
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 type ProjectId,3 type ProjectId,
4 ScheduledTaskId,4 ScheduledTaskId,
5 type ScheduledTask,5 type ScheduledTask,
6 type ScheduledTaskUpsertSchedule,
6 type ModelSelection,7 type ModelSelection,
7 type RuntimeMode,8 type RuntimeMode,
8 type ProviderInteractionMode,9 type ProviderInteractionMode,
...@@ -43,7 +44,7 @@ export function validateScheduledTasksSearch(raw: Record<string, unknown>) {...@@ -43,7 +44,7 @@ export function validateScheduledTasksSearch(raw: Record<string, unknown>) {
43 };44 };
44}45}
4546
46type ScheduleMode = "fixed" | "interval";47export type ScheduleMode = "fixed" | "interval" | "webhook";
47export type WorkspaceMode = "root" | "worktree" | "existing_worktree";48export type WorkspaceMode = "root" | "worktree" | "existing_worktree";
4849
49export interface DraftState {50export interface DraftState {
...@@ -71,6 +72,49 @@ export interface DraftState {...@@ -71,6 +72,49 @@ export interface DraftState {
71 * (reasoning, temperature, …) when the model itself is left unchanged.72 * (reasoning, temperature, …) when the model itself is left unchanged.
72 */73 */
73 readonly baseModelSelection: ModelSelection | null;74 readonly baseModelSelection: ModelSelection | null;
75 readonly signatureEnabled: boolean;
76 readonly signatureHeader: string;
77 readonly signatureEncoding: "hex" | "base64";
78 readonly signaturePrefix: string;
79 /** Write-only: empty keeps the secret already stored on the server. */
80 readonly signatureSecret: string;
81}
82
83/** GitHub's signature settings, the most common sender. */
84export const WEBHOOK_SIGNATURE_DEFAULTS = {
85 signatureHeader: "x-hub-signature-256",
86 signatureEncoding: "hex",
87 signaturePrefix: "sha256=",
88} as const;
89
90/** Prompt a new webhook task starts with: the whole request, which the user can narrow down. */
91export const DEFAULT_WEBHOOK_PROMPT = "Handle this webhook:\n{{request}}";
92
93export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedule {
94 if (draft.scheduleMode === "webhook") {
95 const secret = draft.signatureSecret.trim();
96 return {
97 type: "webhook",
98 signature: draft.signatureEnabled
99 ? {
100 header: draft.signatureHeader.trim(),
101 encoding: draft.signatureEncoding,
102 prefix: draft.signaturePrefix,
103 ...(secret ? { secret } : {}),
104 }
105 : null,
106 };
107 }
108 if (draft.scheduleMode === "interval") {
109 const everyMs = Math.round(Number(draft.intervalMinutes) * 60_000);
110 return { type: "interval", everyMs };
111 }
112 const selectedEveryDay = draft.weekdays.size === 0 || draft.weekdays.size === 7;
113 return {
114 type: "fixed_time",
115 timeOfDay: draft.timeOfDay || "09:00",
116 ...(selectedEveryDay ? {} : { weekdays: [...draft.weekdays].toSorted() }),
117 };
74}118}
75119
76export function taskToDraft(task: ScheduledTask): DraftState {120export function taskToDraft(task: ScheduledTask): DraftState {
...@@ -84,7 +128,8 @@ export function taskToDraft(task: ScheduledTask): DraftState {...@@ -84,7 +128,8 @@ export function taskToDraft(task: ScheduledTask): DraftState {
84 title: task.title,128 title: task.title,
85 prompt: task.prompt,129 prompt: task.prompt,
86 enabled: task.enabled,130 enabled: task.enabled,
87 scheduleMode: schedule.type === "interval" ? "interval" : "fixed",131 scheduleMode:
132 schedule.type === "interval" ? "interval" : schedule.type === "webhook" ? "webhook" : "fixed",
88 intervalMinutes:133 intervalMinutes:
89 schedule.type === "interval" ? String(Math.max(1, schedule.everyMs / 60_000)) : "15",134 schedule.type === "interval" ? String(Math.max(1, schedule.everyMs / 60_000)) : "15",
90 timeOfDay: schedule.type === "fixed_time" ? schedule.timeOfDay : "09:00",135 timeOfDay: schedule.type === "fixed_time" ? schedule.timeOfDay : "09:00",
...@@ -105,6 +150,15 @@ export function taskToDraft(task: ScheduledTask): DraftState {...@@ -105,6 +150,15 @@ export function taskToDraft(task: ScheduledTask): DraftState {
105 runtimeMode: task.runtimeMode,150 runtimeMode: task.runtimeMode,
106 interactionMode: task.interactionMode,151 interactionMode: task.interactionMode,
107 baseModelSelection: task.modelSelection,152 baseModelSelection: task.modelSelection,
153 ...(schedule.type === "webhook" && schedule.signature !== null
154 ? {
155 signatureEnabled: true,
156 signatureHeader: schedule.signature.header,
157 signatureEncoding: schedule.signature.encoding,
158 signaturePrefix: schedule.signature.prefix,
159 }
160 : { signatureEnabled: false, ...WEBHOOK_SIGNATURE_DEFAULTS }),
161 signatureSecret: "",
108 };162 };
109}163}
110164
docs/user/project-settings.md+25
...@@ -63,6 +63,31 @@ environment's time zone, which may differ from your phone's....@@ -63,6 +63,31 @@ environment's time zone, which may differ from your phone's.
63You can edit, pause, resume, run immediately, or delete a task from the list.63You can edit, pause, resume, run immediately, or delete a task from the list.
64Leaving an edited form asks before discarding unsaved changes.64Leaving an edited form asks before discarding unsaved changes.
6565
66## Webhook automations
67
68In **Settings → Scheduled tasks**, choose **On webhook** as a task's schedule to run it whenever another service
69calls its URL, such as GitHub on a new pull request or a CI job that failed.
70After you save the task, copy its URL from the editor. If the environment uses a
71[T3 Connect](remote-access.md) managed tunnel, the URL is public; otherwise it
72works anywhere the environment itself is reachable. **Rotate** replaces the URL and
73the old one stops working.
74
75The prompt decides what the agent sees. Placeholders pull values out of the
76request: `{{body.path}}` for a JSON or form field, `{{headers.name}}`,
77`{{query.name}}`, `{{body}}` for the raw body, and `{{request}}` for everything.
78For example, `Review this PR: {{body.pull_request.html_url}}` sends only the
79pull request link. A placeholder with no value is left empty.
80
81For GitHub, turn on **Require signature**, keep the header
82`x-hub-signature-256`, hex encoding and the `sha256=` prefix, and enter the
83same secret in the repository's webhook settings with content type
84`application/json`. Requests without a valid signature are rejected.
85
86On desktop and web, pick **Deliveries** from a task's menu to see recent
87requests and the prompt each one produced. If the environment is offline, the sender gets an error and
88nothing runs; redeliver from the sender, such as GitHub's **Recent Deliveries**,
89once it is back.
90
66## Defaults and inheritance91## Defaults and inheritance
6792
68General contains the model and workspace for new threads. Integrations controls agent browser93General contains the model and workspace for new threads. Integrations controls agent browser
packages/client-runtime/src/state/server.ts+8-8
...@@ -1076,6 +1076,14 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -1076,6 +1076,14 @@ export function createServerEnvironmentAtoms<R, E>(
1076 label: "environment-data:server:process-resource-history",1076 label: "environment-data:server:process-resource-history",
1077 tag: WS_METHODS.serverGetProcessResourceHistory,1077 tag: WS_METHODS.serverGetProcessResourceHistory,
1078 }),1078 }),
1079 scheduledTaskWebhookDeliveries: createEnvironmentRpcQueryAtomFamily(runtime, {
1080 label: "environment-data:server:scheduled-task:webhook-deliveries",
1081 tag: WS_METHODS.scheduledTasksListWebhookDeliveries,
1082 }),
1083 scheduledTaskWebhookDelivery: createEnvironmentRpcQueryAtomFamily(runtime, {
1084 label: "environment-data:server:scheduled-task:webhook-delivery",
1085 tag: WS_METHODS.scheduledTasksGetWebhookDelivery,
1086 }),
1079 /** Live scheduled-task list: snapshot on subscribe, fresh list after every server-side change. */1087 /** Live scheduled-task list: snapshot on subscribe, fresh list after every server-side change. */
1080 scheduledTasksLive: createEnvironmentRpcSubscriptionAtomFamily(runtime, {1088 scheduledTasksLive: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
1081 label: "environment-data:server:scheduled-tasks:live",1089 label: "environment-data:server:scheduled-tasks:live",
...@@ -1291,14 +1299,6 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -1291,14 +1299,6 @@ export function createServerEnvironmentAtoms<R, E>(
1291 scheduler: configScheduler,1299 scheduler: configScheduler,
1292 concurrency: configConcurrency,1300 concurrency: configConcurrency,
1293 }),1301 }),
1294 listScheduledTaskWebhookDeliveries: createEnvironmentRpcCommand(runtime, {
1295 label: "environment-data:server:scheduled-task:list-webhook-deliveries",
1296 tag: WS_METHODS.scheduledTasksListWebhookDeliveries,
1297 }),
1298 getScheduledTaskWebhookDelivery: createEnvironmentRpcCommand(runtime, {
1299 label: "environment-data:server:scheduled-task:get-webhook-delivery",
1300 tag: WS_METHODS.scheduledTasksGetWebhookDelivery,
1301 }),
1302 refreshUsageRates: createEnvironmentRpcCommand(runtime, {1302 refreshUsageRates: createEnvironmentRpcCommand(runtime, {
1303 label: "environment-data:server:refresh-usage-rates",1303 label: "environment-data:server:refresh-usage-rates",
1304 tag: WS_METHODS.serverRefreshUsageRates,1304 tag: WS_METHODS.serverRefreshUsageRates,