| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { |
| 3 | 3 | Clock3Icon, |
| 4 | CopyIcon, |
| 5 | InboxIcon, |
| 4 | 6 | MoreHorizontalIcon, |
| 5 | 7 | PencilIcon, |
| 6 | 8 | PlayIcon, |
| ... | ... | @@ -17,6 +19,8 @@ import type { |
| 17 | 19 | ScheduledTaskId, |
| 18 | 20 | ScheduledTaskSchedule, |
| 19 | 21 | ScheduledTaskUpsertInput, |
| 22 | ScheduledTaskWebhookDeliveryOutcome, |
| 23 | ScheduledTaskWebhookDeliverySummary, |
| 20 | 24 | ThreadId, |
| 21 | 25 | } from "@t3tools/contracts"; |
| 22 | 26 | import { |
| ... | ... | @@ -37,7 +41,14 @@ import { |
| 37 | 41 | deriveProviderInstanceEntries, |
| 38 | 42 | sortProviderInstanceEntries, |
| 39 | 43 | } from "../../providerInstances"; |
| 40 | | import { useEnvironment, type EnvironmentPresentation } from "../../state/environments"; |
| 44 | import { requestConfirmDialog } from "../../confirmDialog"; |
| 45 | import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; |
| 46 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 47 | import { |
| 48 | useEnvironment, |
| 49 | useEnvironmentHttpBaseUrl, |
| 50 | type EnvironmentPresentation, |
| 51 | } from "../../state/environments"; |
| 41 | 52 | import { useProjects } from "../../state/entities"; |
| 42 | 53 | import { useEnvironmentQuery } from "../../state/query"; |
| 43 | 54 | import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; |
| ... | ... | @@ -46,10 +57,14 @@ import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker"; |
| 46 | 57 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; |
| 47 | 58 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 48 | 59 | import { |
| 60 | DEFAULT_WEBHOOK_PROMPT, |
| 61 | WEBHOOK_SIGNATURE_DEFAULTS, |
| 49 | 62 | matchesScheduledTaskScope, |
| 63 | scheduleFromDraft, |
| 50 | 64 | scheduledTaskDefaultModel, |
| 51 | 65 | taskToDraft, |
| 52 | 66 | type DraftState, |
| 67 | type ScheduleMode, |
| 53 | 68 | type WorkspaceMode, |
| 54 | 69 | } from "./scheduledTasksSettings.logic"; |
| 55 | 70 | import { Label } from "../ui/label"; |
| ... | ... | @@ -112,6 +127,9 @@ const EMPTY_DRAFT: DraftState = { |
| 112 | 127 | runtimeMode: "full-access", |
| 113 | 128 | interactionMode: "default", |
| 114 | 129 | baseModelSelection: null, |
| 130 | signatureEnabled: false, |
| 131 | ...WEBHOOK_SIGNATURE_DEFAULTS, |
| 132 | signatureSecret: "", |
| 115 | 133 | }; |
| 116 | 134 | |
| 117 | 135 | /** Labelled field: a caption sitting above its control. */ |
| ... | ... | @@ -148,19 +166,6 @@ function splitModelKey(value: string): ModelSelection | null { |
| 148 | 166 | }; |
| 149 | 167 | } |
| 150 | 168 | |
| 151 | | function 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 | | |
| 164 | 169 | export function scheduleLabel(schedule: ScheduledTaskSchedule): string { |
| 165 | 170 | if (schedule.type === "webhook") return "On webhook"; |
| 166 | 171 | if (schedule.type === "interval") { |
| ... | ... | @@ -200,6 +205,20 @@ export function relativeLabel(value: string | null): string { |
| 200 | 205 | return `in ${Math.round(hours / 24)}d`; |
| 201 | 206 | } |
| 202 | 207 | |
| 208 | const 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 | |
| 216 | function deliveryOutcomeVariant(outcome: ScheduledTaskWebhookDeliveryOutcome) { |
| 217 | if (outcome === "accepted") return "success"; |
| 218 | if (outcome === "disabled" || outcome === "rate_limited") return "warning"; |
| 219 | return "error"; |
| 220 | } |
| 221 | |
| 203 | 222 | function statusVariant(status: ScheduledTask["lastRunStatus"]) { |
| 204 | 223 | if (status === "failed") return "error"; |
| 205 | 224 | if (status === "succeeded") return "success"; |
| ... | ... | @@ -313,7 +332,7 @@ function ScheduledTaskEnvironmentSection({ |
| 313 | 332 | const linkedTask = tasks?.find((task) => task.id === taskId); |
| 314 | 333 | const openedLink = useRef(false); |
| 315 | 334 | useEffect(() => { |
| 316 | | if (!openedLink.current && linkedTask && linkedTask.schedule.type !== "webhook") { |
| 335 | if (!openedLink.current && linkedTask) { |
| 317 | 336 | openedLink.current = true; |
| 318 | 337 | onEdit(environment.environmentId, linkedTask); |
| 319 | 338 | } |
| ... | ... | @@ -347,12 +366,6 @@ function ScheduledTaskEnvironmentSection({ |
| 347 | 366 | description="This task no longer exists or is outside the selected project scope." |
| 348 | 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 | 369 | ) : null} |
| 357 | 370 | {tasks.length === 0 ? ( |
| 358 | 371 | <SettingsRow |
| ... | ... | @@ -385,6 +398,8 @@ function ScheduledTaskRow({ |
| 385 | 398 | readonly onEdit: () => void; |
| 386 | 399 | }) { |
| 387 | 400 | const [busy, setBusy] = useState(false); |
| 401 | const [deliveriesOpen, setDeliveriesOpen] = useState(false); |
| 402 | const isWebhook = task.schedule.type === "webhook"; |
| 388 | 403 | const toggle = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, { |
| 389 | 404 | label: "scheduled task enabled", |
| 390 | 405 | }); |
| ... | ... | @@ -422,11 +437,13 @@ function ScheduledTaskRow({ |
| 422 | 437 | <div className="flex flex-wrap items-center gap-2"> |
| 423 | 438 | <span> |
| 424 | 439 | {scheduleLabel(task.schedule)} ·{" "} |
| 425 | | {task.enabled |
| 426 | | ? task.nextRunAt |
| 427 | | ? `Next run ${relativeLabel(task.nextRunAt)}` |
| 428 | | : "Not scheduled" |
| 429 | | : "Paused"} |
| 440 | {!task.enabled |
| 441 | ? "Paused" |
| 442 | : isWebhook |
| 443 | ? "Listening" |
| 444 | : task.nextRunAt |
| 445 | ? `Next run ${relativeLabel(task.nextRunAt)}` |
| 446 | : "Not scheduled"} |
| 430 | 447 | </span> |
| 431 | 448 | {task.lastRunStatus !== "never" ? ( |
| 432 | 449 | <Badge variant={statusVariant(task.lastRunStatus)}>{task.lastRunStatus}</Badge> |
| ... | ... | @@ -456,15 +473,21 @@ function ScheduledTaskRow({ |
| 456 | 473 | <MoreHorizontalIcon className="size-4" /> |
| 457 | 474 | </MenuTrigger> |
| 458 | 475 | <MenuPopup align="end"> |
| 459 | | {/* Webhook tasks are not editable here yet; saving would drop their URL. */} |
| 460 | | <MenuItem onClick={onEdit} disabled={task.schedule.type === "webhook"}> |
| 476 | <MenuItem onClick={onEdit}> |
| 461 | 477 | <PencilIcon /> |
| 462 | 478 | Edit |
| 463 | 479 | </MenuItem> |
| 464 | | <MenuItem onClick={() => void act("run")} disabled={task.schedule.type === "webhook"}> |
| 465 | | <PlayIcon /> |
| 466 | | Run now |
| 467 | | </MenuItem> |
| 480 | {isWebhook ? ( |
| 481 | <MenuItem onClick={() => setDeliveriesOpen(true)}> |
| 482 | <InboxIcon /> |
| 483 | Deliveries |
| 484 | </MenuItem> |
| 485 | ) : ( |
| 486 | <MenuItem onClick={() => void act("run")}> |
| 487 | <PlayIcon /> |
| 488 | Run now |
| 489 | </MenuItem> |
| 490 | )} |
| 468 | 491 | <MenuSeparator /> |
| 469 | 492 | <MenuItem onClick={() => void act("delete")}> |
| 470 | 493 | <Trash2Icon /> |
| ... | ... | @@ -472,12 +495,240 @@ function ScheduledTaskRow({ |
| 472 | 495 | </MenuItem> |
| 473 | 496 | </MenuPopup> |
| 474 | 497 | </Menu> |
| 498 | {deliveriesOpen ? ( |
| 499 | <WebhookDeliveriesDialog |
| 500 | environmentId={environmentId} |
| 501 | task={task} |
| 502 | onClose={() => setDeliveriesOpen(false)} |
| 503 | /> |
| 504 | ) : null} |
| 475 | 505 | </div> |
| 476 | 506 | } |
| 477 | 507 | /> |
| 478 | 508 | ); |
| 479 | 509 | } |
| 480 | 510 | |
| 511 | function 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 | |
| 646 | function 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 | |
| 657 | function 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 | |
| 481 | 732 | function ScheduledTaskEditorDialog({ |
| 482 | 733 | initialEnvironmentId, |
| 483 | 734 | task, |
| ... | ... | @@ -527,6 +778,11 @@ function ScheduledTaskEditorDialog({ |
| 527 | 778 | draft.editingId !== null && |
| 528 | 779 | tasksQuery.data !== null && |
| 529 | 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 | 786 | const selectedProjectId = draft.projectId || projects[0]?.id || ""; |
| 531 | 787 | const selectedProject = projects.find((project) => project.id === selectedProjectId); |
| 532 | 788 | |
| ... | ... | @@ -574,6 +830,16 @@ function ScheduledTaskEditorDialog({ |
| 574 | 830 | return; |
| 575 | 831 | } |
| 576 | 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 | 843 | if ( |
| 578 | 844 | schedule.type === "interval" && |
| 579 | 845 | (!Number.isSafeInteger(schedule.everyMs) || schedule.everyMs < MIN_SCHEDULED_TASK_INTERVAL_MS) |
| ... | ... | @@ -643,7 +909,8 @@ function ScheduledTaskEditorDialog({ |
| 643 | 909 | <DialogHeader> |
| 644 | 910 | <DialogTitle>{draft.editingId ? "Edit task" : "New task"}</DialogTitle> |
| 645 | 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 | 914 | </DialogDescription> |
| 648 | 915 | </DialogHeader> |
| 649 | 916 | |
| ... | ... | @@ -833,17 +1100,116 @@ function ScheduledTaskEditorDialog({ |
| 833 | 1100 | aria-label="Schedule type" |
| 834 | 1101 | value={[draft.scheduleMode]} |
| 835 | 1102 | onValueChange={(values) => { |
| 836 | | const mode = values[0]; |
| 837 | | if (mode === "fixed" || mode === "interval") |
| 838 | | setDraft((current) => ({ ...current, scheduleMode: mode })); |
| 1103 | const mode = values[0] as ScheduleMode | undefined; |
| 1104 | if (mode === "fixed" || mode === "interval" || mode === "webhook") |
| 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 | 1115 | <Toggle value="fixed">At a time</Toggle> |
| 842 | 1116 | <Toggle value="interval">Every interval</Toggle> |
| 1117 | <Toggle value="webhook">On webhook</Toggle> |
| 843 | 1118 | </ToggleGroup> |
| 844 | 1119 | </div> |
| 845 | 1120 | |
| 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 | 1213 | <div className="flex flex-wrap items-center gap-3"> |
| 848 | 1214 | <div className="flex items-center gap-2"> |
| 849 | 1215 | <Label htmlFor="scheduled-task-time">Run at</Label> |