| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 2 | import { |
| 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"; |
| 22 | import { | 26 | import { |
| ... | @@ -37,7 +41,14 @@ import { | ... | @@ -37,7 +41,14 @@ import { |
| 37 | deriveProviderInstanceEntries, | 41 | deriveProviderInstanceEntries, |
| 38 | sortProviderInstanceEntries, | 42 | sortProviderInstanceEntries, |
| 39 | } from "../../providerInstances"; | 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 | import { useProjects } from "../../state/entities"; | 52 | import { useProjects } from "../../state/entities"; |
| 42 | import { useEnvironmentQuery } from "../../state/query"; | 53 | import { useEnvironmentQuery } from "../../state/query"; |
| 43 | import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; | 54 | import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; |
| ... | @@ -46,10 +57,14 @@ import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker"; | ... | @@ -46,10 +57,14 @@ import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker"; |
| 46 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | 57 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; |
| 47 | import { useSettingsScope } from "./SettingsScopeContext"; | 58 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 48 | import { | 59 | import { |
| | 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"; |
| 55 | import { Label } from "../ui/label"; | 70 | import { 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 | }; |
| 116 | | 134 | |
| 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 | } |
| 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 | export function scheduleLabel(schedule: ScheduledTaskSchedule): string { | 169 | export 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 | } |
| 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 | function statusVariant(status: ScheduledTask["lastRunStatus"]) { | 222 | function 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 | <SettingsRow | 371 | <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.enabled | 440 | {!task.enabled |
| 426 | ? task.nextRunAt | 441 | ? "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 | Edit | 478 | 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 now | 482 | <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 | } |
| 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 | function ScheduledTaskEditorDialog({ | 732 | function 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); |
| 532 | | 788 | |
| ... | @@ -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> |
| 649 | | 916 | |
| ... | @@ -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> |
| 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 | <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> |