From 5397a1492f0563591a7825dc7423c5bf519f9f25 Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega Date: Thu, 18 Jun 2026 19:26:04 +0200 Subject: [PATCH] feat(web): add gateway trigger subscriptions and deliveries UI Mirrors the webhook-subscription settings UI for inbound triggers. - Subscriptions section under Settings > Triggers, listing a shared connection's subscriptions with create/edit drawers. - TriggerSubscriptionDrawer binds a workflow revision via the shared EntityPicker + workflowRevisionAdapter, sending the destination as a references dict (workflow_revision) in the /retrieve shape; inputs mapping edited as JSON. Connection locked in edit mode (FK anchor). - TriggerDeliveriesDrawer shows delivery history per subscription. - useTriggerSubscriptions / useTriggerSubscription / useTriggerDeliveries hooks plus entities api wiring; triggerReferenceSchema added. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pages/settings/Triggers/Triggers.tsx | 2 + .../GatewaySubscriptionsSection.tsx | 264 ++++++++++++++ .../src/gatewayTrigger/api/api.ts | 155 ++++++++ .../src/gatewayTrigger/api/index.ts | 9 + .../src/gatewayTrigger/core/types.ts | 170 +++++++++ .../src/gatewayTrigger/hooks/index.ts | 8 + .../hooks/useTriggerDeliveries.ts | 36 ++ .../hooks/useTriggerSubscription.ts | 94 +++++ .../hooks/useTriggerSubscriptions.ts | 60 ++++ .../src/gatewayTrigger/index.ts | 42 ++- .../src/gatewayTrigger/state/atoms.ts | 21 ++ .../src/gatewayTrigger/state/index.ts | 10 +- .../tests/unit/gatewayTriggerApi.test.ts | 109 ++++++ .../drawers/TriggerDeliveriesDrawer.tsx | 162 +++++++++ .../drawers/TriggerSubscriptionDrawer.tsx | 340 ++++++++++++++++++ .../src/gatewayTrigger/index.ts | 2 + 16 files changed, 1480 insertions(+), 4 deletions(-) create mode 100644 web/oss/src/components/pages/settings/Triggers/components/GatewaySubscriptionsSection.tsx create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerDeliveries.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscription.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts create mode 100644 web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx create mode 100644 web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx diff --git a/web/oss/src/components/pages/settings/Triggers/Triggers.tsx b/web/oss/src/components/pages/settings/Triggers/Triggers.tsx index 0c65e99f53..1bb4ebdf6c 100644 --- a/web/oss/src/components/pages/settings/Triggers/Triggers.tsx +++ b/web/oss/src/components/pages/settings/Triggers/Triggers.tsx @@ -1,9 +1,11 @@ +import GatewaySubscriptionsSection from "./components/GatewaySubscriptionsSection" import GatewayTriggersSection from "./components/GatewayTriggersSection" export default function Triggers() { return (
+
) } diff --git a/web/oss/src/components/pages/settings/Triggers/components/GatewaySubscriptionsSection.tsx b/web/oss/src/components/pages/settings/Triggers/components/GatewaySubscriptionsSection.tsx new file mode 100644 index 0000000000..6e06bde2cc --- /dev/null +++ b/web/oss/src/components/pages/settings/Triggers/components/GatewaySubscriptionsSection.tsx @@ -0,0 +1,264 @@ +import {useCallback, useMemo} from "react" + +import { + deliveriesDrawerAtom, + subscriptionDrawerAtom, + useTriggerConnectionsQuery, + useTriggerSubscription, + useTriggerSubscriptions, + type TriggerSubscription, +} from "@agenta/entities/gatewayTrigger" +import {TriggerDeliveriesDrawer, TriggerSubscriptionDrawer} from "@agenta/entity-ui/gatewayTrigger" +import {MoreOutlined} from "@ant-design/icons" +import { + ArrowsClockwise, + GearSix, + ListChecks, + PencilSimpleLine, + Plus, + Trash, + XCircle, +} from "@phosphor-icons/react" +import {Button, Dropdown, Empty, Table, Tag, Typography, message} from "antd" +import type {ColumnsType} from "antd/es/table" +import {useSetAtom} from "jotai" + +import {formatDay} from "@/oss/lib/helpers/dateTimeHelper" + +export default function GatewaySubscriptionsSection() { + const {subscriptions, isLoading} = useTriggerSubscriptions() + const {connections} = useTriggerConnectionsQuery() + const {revoke, refresh, remove, isMutating} = useTriggerSubscription() + const openDrawer = useSetAtom(subscriptionDrawerAtom) + const openDeliveries = useSetAtom(deliveriesDrawerAtom) + + const connectionLabel = useCallback( + (connectionId?: string) => { + const c = connections.find((conn) => conn.id === connectionId) + return c ? c.name || c.slug || c.integration_key : (connectionId ?? "-") + }, + [connections], + ) + + const handleCreate = useCallback(() => openDrawer({}), [openDrawer]) + + const handleEdit = useCallback( + (record: TriggerSubscription) => openDrawer({subscriptionId: record.id ?? undefined}), + [openDrawer], + ) + + const handleRevoke = useCallback( + async (record: TriggerSubscription) => { + if (!record.id) return + try { + await revoke(record.id) + message.success("Subscription revoked") + } catch { + message.error("Failed to revoke subscription") + } + }, + [revoke], + ) + + const handleRefresh = useCallback( + async (record: TriggerSubscription) => { + if (!record.id) return + try { + await refresh(record.id) + message.success("Subscription refreshed") + } catch { + message.error("Failed to refresh subscription") + } + }, + [refresh], + ) + + const handleDelete = useCallback( + async (record: TriggerSubscription) => { + if (!record.id) return + try { + await remove(record.id) + message.success("Subscription deleted") + } catch { + message.error("Failed to delete subscription") + } + }, + [remove], + ) + + const columns: ColumnsType = useMemo( + () => [ + { + title: "Name", + key: "name", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => ( + {record.name || record.id || "-"} + ), + }, + { + title: "Connection", + key: "connection", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => ( + {connectionLabel(record.connection_id)} + ), + }, + { + title: "Event", + key: "event", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => ( + + {record.data?.event_key ?? "-"} + + ), + }, + { + title: "Status", + key: "status", + onHeaderCell: () => ({style: {minWidth: 120}}), + render: (_, record) => + !record.valid ? ( + Invalid + ) : record.enabled ? ( + Enabled + ) : ( + Disabled + ), + }, + { + title: "Created at", + dataIndex: "created_at", + key: "created_at", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (value: string) => + value ? formatDay({date: value, outputFormat: "YYYY-MM-DD HH:mm"}) : "-", + }, + { + title: , + key: "actions", + width: 61, + fixed: "right" as const, + align: "center" as const, + render: (_, record) => ( + , + onClick: (e) => { + e.domEvent.stopPropagation() + if (record.id) + openDeliveries({ + subscriptionId: record.id, + subscriptionName: record.name ?? undefined, + }) + }, + }, + { + key: "edit", + label: "Edit", + icon: , + onClick: (e) => { + e.domEvent.stopPropagation() + handleEdit(record) + }, + }, + { + key: "refresh", + label: "Refresh", + icon: , + onClick: (e) => { + e.domEvent.stopPropagation() + handleRefresh(record) + }, + }, + {type: "divider" as const}, + { + key: "revoke", + label: "Revoke", + icon: , + onClick: (e) => { + e.domEvent.stopPropagation() + handleRevoke(record) + }, + }, + { + key: "delete", + label: "Delete", + icon: , + danger: true, + onClick: (e) => { + e.domEvent.stopPropagation() + handleDelete(record) + }, + }, + ], + }} + > + + + + + Bind a provider event to a workflow. Each subscription dispatches matching + events to its bound workflow. + + + + className="ph-no-capture" + columns={columns} + dataSource={subscriptions} + rowKey={(record) => record.id ?? record.slug ?? record.data?.event_key ?? ""} + bordered + pagination={false} + loading={isLoading || isMutating} + locale={{emptyText: }} + onRow={(record) => ({ + onClick: () => handleEdit(record), + className: "cursor-pointer", + })} + /> + + + + + + ) +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts b/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts index 9de7f2d628..08faeca609 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts @@ -18,11 +18,23 @@ import { triggerCatalogProviderResponseSchema, triggerCatalogProvidersResponseSchema, triggerConnectionsResponseSchema, + triggerDeliveriesResponseSchema, + triggerDeliveryResponseSchema, + triggerSubscriptionResponseSchema, + triggerSubscriptionsResponseSchema, type TriggerCatalogEventResponse, type TriggerCatalogEventsResponse, type TriggerCatalogProviderResponse, type TriggerCatalogProvidersResponse, type TriggerConnectionsResponse, + type TriggerDeliveriesResponse, + type TriggerDeliveryQuery, + type TriggerDeliveryResponse, + type TriggerSubscriptionCreate, + type TriggerSubscriptionEdit, + type TriggerSubscriptionQuery, + type TriggerSubscriptionResponse, + type TriggerSubscriptionsResponse, } from "../core/types" import {axios, projectScopedParams, triggersBaseUrl} from "./client" @@ -117,3 +129,146 @@ export const queryTriggerConnections = async (params?: { ) return (validated as TriggerConnectionsResponse | null) ?? {count: 0, connections: []} } + +// --- Subscriptions --- + +export const queryTriggerSubscriptions = async ( + subscription?: TriggerSubscriptionQuery, +): Promise => { + const {data} = await axios.post( + `${triggersBaseUrl()}/subscriptions/query`, + {subscription: subscription ?? null}, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionsResponseSchema, + data, + "[queryTriggerSubscriptions]", + ) ?? {count: 0, subscriptions: []} + ) +} + +export const fetchTriggerSubscription = async ( + subscriptionId: string, +): Promise => { + const {data} = await axios.get( + `${triggersBaseUrl()}/subscriptions/${subscriptionId}`, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionResponseSchema, + data, + "[fetchTriggerSubscription]", + ) ?? {count: 0, subscription: null} + ) +} + +export const createTriggerSubscription = async ( + subscription: TriggerSubscriptionCreate, +): Promise => { + const {data} = await axios.post( + `${triggersBaseUrl()}/subscriptions/`, + {subscription}, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionResponseSchema, + data, + "[createTriggerSubscription]", + ) ?? {count: 0, subscription: null} + ) +} + +export const editTriggerSubscription = async ( + subscription: TriggerSubscriptionEdit, +): Promise => { + const {data} = await axios.put( + `${triggersBaseUrl()}/subscriptions/${subscription.id}`, + {subscription}, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionResponseSchema, + data, + "[editTriggerSubscription]", + ) ?? {count: 0, subscription: null} + ) +} + +export const refreshTriggerSubscription = async ( + subscriptionId: string, +): Promise => { + const {data} = await axios.post( + `${triggersBaseUrl()}/subscriptions/${subscriptionId}/refresh`, + {}, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionResponseSchema, + data, + "[refreshTriggerSubscription]", + ) ?? {count: 0, subscription: null} + ) +} + +export const revokeTriggerSubscription = async ( + subscriptionId: string, +): Promise => { + const {data} = await axios.post( + `${triggersBaseUrl()}/subscriptions/${subscriptionId}/revoke`, + {}, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerSubscriptionResponseSchema, + data, + "[revokeTriggerSubscription]", + ) ?? {count: 0, subscription: null} + ) +} + +export const deleteTriggerSubscription = async (subscriptionId: string): Promise => { + await axios.delete( + `${triggersBaseUrl()}/subscriptions/${subscriptionId}`, + projectScopedParams(), + ) +} + +// --- Deliveries (read-only) --- + +export const queryTriggerDeliveries = async ( + delivery?: TriggerDeliveryQuery, +): Promise => { + const {data} = await axios.post( + `${triggersBaseUrl()}/deliveries/query`, + {delivery: delivery ?? null}, + projectScopedParams(), + ) + return ( + safeParseWithLogging(triggerDeliveriesResponseSchema, data, "[queryTriggerDeliveries]") ?? { + count: 0, + deliveries: [], + } + ) +} + +export const fetchTriggerDelivery = async ( + deliveryId: string, +): Promise => { + const {data} = await axios.get( + `${triggersBaseUrl()}/deliveries/${deliveryId}`, + projectScopedParams(), + ) + return ( + safeParseWithLogging(triggerDeliveryResponseSchema, data, "[fetchTriggerDelivery]") ?? { + count: 0, + delivery: null, + } + ) +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts index c307965ae8..f99959cd17 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts @@ -1,8 +1,17 @@ export { + createTriggerSubscription, + deleteTriggerSubscription, + editTriggerSubscription, + fetchTriggerDelivery, fetchTriggerEvent, fetchTriggerEvents, fetchTriggerProvider, fetchTriggerProviders, + fetchTriggerSubscription, queryTriggerConnections, + queryTriggerDeliveries, + queryTriggerSubscriptions, + refreshTriggerSubscription, + revokeTriggerSubscription, } from "./api" export {triggersBaseUrl, projectScopedParams} from "./client" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts b/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts index 16b6d8ea26..1ba1a27bb4 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts @@ -129,3 +129,173 @@ export type TriggerConnection = ToolConnection export type TriggerConnectionsResponse = ToolConnectionsResponse export {isConnectionActive, isConnectionValid} from "../../gatewayTool/core/types" + +// --------------------------------------------------------------------------- +// Subscriptions — a standing watch binding a provider event to a workflow. +// +// Mirrors the frozen backend DTOs (`api/oss/src/core/triggers/dtos.py`: +// TriggerSubscription / *Create / *Edit / *Query). Validated at the axios +// boundary; the aliases swap to `AgentaApi.*` once the triggers resource lands +// in the Fern client. +// --------------------------------------------------------------------------- + +// A workflow reference (the /retrieve shape): {id, slug?, version?}. +export const triggerReferenceSchema = z + .object({ + id: z.string().nullish(), + slug: z.string().nullish(), + version: z.string().nullish(), + }) + .passthrough() +export type TriggerReference = z.infer + +export const triggerSelectorSchema = z + .object({ + key: z.string().nullish(), + path: z.string().nullish(), + }) + .passthrough() +export type TriggerSelector = z.infer + +export const triggerSubscriptionDataSchema = z + .object({ + event_key: z.string(), + ti_id: z.string().nullish(), + trigger_config: z.record(z.string(), z.unknown()).nullish(), + inputs_fields: z.record(z.string(), z.unknown()).nullish(), + references: z.record(z.string(), triggerReferenceSchema).nullish(), + selector: triggerSelectorSchema.nullish(), + }) + .passthrough() +export type TriggerSubscriptionData = z.infer + +export const triggerSubscriptionSchema = z + .object({ + id: z.string().nullish(), + slug: z.string().nullish(), + name: z.string().nullish(), + description: z.string().nullish(), + flags: jsonRecordSchema, + tags: jsonRecordSchema, + meta: jsonRecordSchema, + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + deleted_at: z.string().nullish(), + created_by_id: z.string().nullish(), + updated_by_id: z.string().nullish(), + deleted_by_id: z.string().nullish(), + connection_id: z.string(), + data: triggerSubscriptionDataSchema, + enabled: z.boolean().default(true), + valid: z.boolean().default(true), + }) + .passthrough() +export type TriggerSubscription = z.infer + +export const triggerSubscriptionResponseSchema = z + .object({ + count: z.number().default(0), + subscription: triggerSubscriptionSchema.nullish(), + }) + .passthrough() +export type TriggerSubscriptionResponse = z.infer + +export const triggerSubscriptionsResponseSchema = z + .object({ + count: z.number().default(0), + subscriptions: z.array(triggerSubscriptionSchema).default([]), + }) + .passthrough() +export type TriggerSubscriptionsResponse = z.infer + +// Create body (Header + Metadata + connection_id + data); no id. +export interface TriggerSubscriptionCreate { + name?: string | null + description?: string | null + flags?: Record | null + tags?: Record | null + meta?: Record | null + connection_id: string + data: TriggerSubscriptionData +} + +// Edit body — full PUT: Identifier + Header + Metadata + connection_id + data + flags. +export interface TriggerSubscriptionEdit extends TriggerSubscriptionCreate { + id: string + enabled: boolean + valid: boolean +} + +export interface TriggerSubscriptionQuery { + name?: string + connection_id?: string + event_key?: string +} + +// --------------------------------------------------------------------------- +// Deliveries — read-only audit rows, one per inbound event dispatched. +// Mirrors `TriggerDelivery` / `TriggerDeliveryQuery`. `status` is the shared +// `core.shared.dtos.Status` (timestamp/type/code/message/stacktrace). +// --------------------------------------------------------------------------- + +export const triggerStatusSchema = z + .object({ + timestamp: z.string().nullish(), + type: z.string().nullish(), + code: z.string().nullish(), + message: z.string().nullish(), + stacktrace: z.string().nullish(), + }) + .passthrough() +export type TriggerStatus = z.infer + +export const triggerDeliveryDataSchema = z + .object({ + event_key: z.string().nullish(), + references: z.record(z.string(), triggerReferenceSchema).nullish(), + inputs: z.record(z.string(), z.unknown()).nullish(), + result: z.record(z.string(), z.unknown()).nullish(), + error: z.string().nullish(), + }) + .passthrough() +export type TriggerDeliveryData = z.infer + +export const triggerDeliverySchema = z + .object({ + id: z.string().nullish(), + slug: z.string().nullish(), + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + deleted_at: z.string().nullish(), + created_by_id: z.string().nullish(), + updated_by_id: z.string().nullish(), + deleted_by_id: z.string().nullish(), + status: triggerStatusSchema, + data: triggerDeliveryDataSchema.nullish(), + subscription_id: z.string(), + event_id: z.string(), + }) + .passthrough() +export type TriggerDelivery = z.infer + +export const triggerDeliveryResponseSchema = z + .object({ + count: z.number().default(0), + delivery: triggerDeliverySchema.nullish(), + }) + .passthrough() +export type TriggerDeliveryResponse = z.infer + +export const triggerDeliveriesResponseSchema = z + .object({ + count: z.number().default(0), + deliveries: z.array(triggerDeliverySchema).default([]), + }) + .passthrough() +export type TriggerDeliveriesResponse = z.infer + +export interface TriggerDeliveryQuery { + status?: TriggerStatus + subscription_id?: string + event_id?: string +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts index 3cbfeb2535..31afdb9936 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts @@ -6,3 +6,11 @@ export { useTriggerConnectionsQuery, useTriggerIntegrationConnections, } from "./useTriggerConnections" +export { + triggerConnectionSubscriptionsAtomFamily, + triggerSubscriptionsQueryAtom, + useTriggerConnectionSubscriptions, + useTriggerSubscriptions, +} from "./useTriggerSubscriptions" +export {triggerSubscriptionQueryAtomFamily, useTriggerSubscription} from "./useTriggerSubscription" +export {triggerDeliveriesAtomFamily, useTriggerDeliveries} from "./useTriggerDeliveries" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerDeliveries.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerDeliveries.ts new file mode 100644 index 0000000000..c35f7156ae --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerDeliveries.ts @@ -0,0 +1,36 @@ +import {useMemo} from "react" + +import {useAtomValue} from "jotai" +import {atomFamily} from "jotai/utils" +import {atomWithQuery} from "jotai-tanstack-query" + +import {queryTriggerDeliveries} from "../api" +import type {TriggerDelivery, TriggerDeliveriesResponse} from "../core/types" + +// Deliveries scoped to one subscription. Distinct from subscription keys. +export const triggerDeliveriesAtomFamily = atomFamily((subscriptionId: string) => + atomWithQuery(() => ({ + queryKey: ["triggers", "deliveries", subscriptionId], + queryFn: () => queryTriggerDeliveries({subscription_id: subscriptionId}), + staleTime: 15_000, + refetchOnWindowFocus: false, + enabled: !!subscriptionId, + })), +) + +export const useTriggerDeliveries = (subscriptionId?: string) => { + const query = useAtomValue(triggerDeliveriesAtomFamily(subscriptionId ?? "")) + + const deliveries = useMemo( + () => query.data?.deliveries ?? [], + [query.data?.deliveries], + ) + + return { + deliveries, + count: query.data?.count ?? 0, + isLoading: subscriptionId ? query.isPending : false, + error: query.error, + refetch: query.refetch, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscription.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscription.ts new file mode 100644 index 0000000000..cbb9122b1e --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscription.ts @@ -0,0 +1,94 @@ +import {useCallback, useState} from "react" + +import {queryClient} from "@agenta/shared/api" +import {useAtomValue} from "jotai" +import {atomFamily} from "jotai/utils" +import {atomWithQuery} from "jotai-tanstack-query" + +import { + createTriggerSubscription, + deleteTriggerSubscription, + editTriggerSubscription, + fetchTriggerSubscription, + refreshTriggerSubscription, + revokeTriggerSubscription, +} from "../api" +import type { + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionEdit, + TriggerSubscriptionResponse, +} from "../core/types" + +const invalidateSubscriptions = () => { + queryClient.invalidateQueries({queryKey: ["triggers", "subscriptions"]}) +} + +// Single subscription (used to source the full PUT body before editing). +export const triggerSubscriptionQueryAtomFamily = atomFamily((subscriptionId: string) => + atomWithQuery(() => ({ + queryKey: ["triggers", "subscriptions", "detail", subscriptionId], + queryFn: () => fetchTriggerSubscription(subscriptionId), + staleTime: 30_000, + refetchOnWindowFocus: false, + enabled: !!subscriptionId, + })), +) + +export const useTriggerSubscription = (subscriptionId?: string) => { + const query = useAtomValue(triggerSubscriptionQueryAtomFamily(subscriptionId ?? "")) + const [isMutating, setIsMutating] = useState(false) + + const run = useCallback( + async ( + fn: () => Promise, + ): Promise => { + setIsMutating(true) + try { + const res = await fn() + invalidateSubscriptions() + return res.subscription ?? null + } finally { + setIsMutating(false) + } + }, + [], + ) + + const create = useCallback( + (subscription: TriggerSubscriptionCreate) => + run(() => createTriggerSubscription(subscription)), + [run], + ) + + const edit = useCallback( + (subscription: TriggerSubscriptionEdit) => run(() => editTriggerSubscription(subscription)), + [run], + ) + + const revoke = useCallback((id: string) => run(() => revokeTriggerSubscription(id)), [run]) + + const refresh = useCallback((id: string) => run(() => refreshTriggerSubscription(id)), [run]) + + const remove = useCallback(async (id: string) => { + setIsMutating(true) + try { + await deleteTriggerSubscription(id) + invalidateSubscriptions() + } finally { + setIsMutating(false) + } + }, []) + + return { + subscription: subscriptionId ? (query.data?.subscription ?? null) : null, + isLoading: subscriptionId ? query.isPending : false, + error: query.error, + isMutating, + create, + edit, + revoke, + refresh, + remove, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts new file mode 100644 index 0000000000..80df5d48b3 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerSubscriptions.ts @@ -0,0 +1,60 @@ +import {useMemo} from "react" + +import {useAtomValue} from "jotai" +import {atomFamily} from "jotai/utils" +import {atomWithQuery} from "jotai-tanstack-query" + +import {queryTriggerSubscriptions} from "../api" +import type {TriggerSubscription, TriggerSubscriptionsResponse} from "../core/types" + +// Distinct from the catalog/connection keys (["triggers", "catalog"|"connections"]). +export const triggerSubscriptionsQueryAtom = atomWithQuery(() => ({ + queryKey: ["triggers", "subscriptions"], + queryFn: () => queryTriggerSubscriptions(), + staleTime: 30_000, + refetchOnWindowFocus: false, +})) + +export const useTriggerSubscriptions = () => { + const query = useAtomValue(triggerSubscriptionsQueryAtom) + + const subscriptions = useMemo( + () => query.data?.subscriptions ?? [], + [query.data?.subscriptions], + ) + + return { + subscriptions, + count: query.data?.count ?? 0, + isLoading: query.isPending, + error: query.error, + refetch: query.refetch, + } +} + +// Subscriptions scoped to a single connection. +export const triggerConnectionSubscriptionsAtomFamily = atomFamily((connectionId: string) => + atomWithQuery(() => ({ + queryKey: ["triggers", "subscriptions", "connection", connectionId], + queryFn: () => queryTriggerSubscriptions({connection_id: connectionId}), + staleTime: 30_000, + refetchOnWindowFocus: false, + enabled: !!connectionId, + })), +) + +export const useTriggerConnectionSubscriptions = (connectionId: string) => { + const query = useAtomValue(triggerConnectionSubscriptionsAtomFamily(connectionId)) + + const subscriptions = useMemo( + () => query.data?.subscriptions ?? [], + [query.data?.subscriptions], + ) + + return { + subscriptions, + count: query.data?.count ?? 0, + isLoading: query.isPending, + error: query.error, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/index.ts index 1177cb2375..24fb926f9c 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/index.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/index.ts @@ -24,7 +24,22 @@ export type { TriggerCatalogProvidersResponse, TriggerConnection, TriggerConnectionsResponse, + TriggerDelivery, + TriggerDeliveriesResponse, + TriggerDeliveryData, + TriggerDeliveryQuery, + TriggerDeliveryResponse, TriggerProviderKind, + TriggerReference, + TriggerSelector, + TriggerStatus, + TriggerSubscription, + TriggerSubscriptionCreate, + TriggerSubscriptionData, + TriggerSubscriptionEdit, + TriggerSubscriptionQuery, + TriggerSubscriptionResponse, + TriggerSubscriptionsResponse, } from "./core" export {isConnectionActive, isConnectionValid} from "./core" @@ -33,19 +48,34 @@ export {isConnectionActive, isConnectionValid} from "./core" // --------------------------------------------------------------------------- export { + createTriggerSubscription, + deleteTriggerSubscription, + editTriggerSubscription, + fetchTriggerDelivery, fetchTriggerEvent, fetchTriggerEvents, fetchTriggerProvider, fetchTriggerProviders, + fetchTriggerSubscription, queryTriggerConnections, + queryTriggerDeliveries, + queryTriggerSubscriptions, + refreshTriggerSubscription, + revokeTriggerSubscription, } from "./api" // --------------------------------------------------------------------------- // STATE — drawer + selection atoms // --------------------------------------------------------------------------- -export {eventsDrawerAtom, eventSearchAtom, selectedCatalogEventAtom} from "./state" -export type {EventsDrawerState} from "./state" +export { + deliveriesDrawerAtom, + eventsDrawerAtom, + eventSearchAtom, + selectedCatalogEventAtom, + subscriptionDrawerAtom, +} from "./state" +export type {DeliveriesDrawerState, EventsDrawerState, SubscriptionDrawerState} from "./state" // --------------------------------------------------------------------------- // HOOKS — query hooks for React consumers @@ -55,10 +85,18 @@ export { catalogEventsInfiniteFamily, eventsSearchAtom, triggerConnectionsQueryAtom, + triggerConnectionSubscriptionsAtomFamily, + triggerDeliveriesAtomFamily, triggerEventDetailQueryFamily, triggerIntegrationConnectionsAtomFamily, + triggerSubscriptionQueryAtomFamily, + triggerSubscriptionsQueryAtom, useCatalogEvents, useTriggerConnectionsQuery, + useTriggerConnectionSubscriptions, + useTriggerDeliveries, useTriggerEvent, useTriggerIntegrationConnections, + useTriggerSubscription, + useTriggerSubscriptions, } from "./hooks" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts b/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts index 975e2f378a..c9a823eeab 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts @@ -15,3 +15,24 @@ export const eventsDrawerAtom = atom(null) // Drawer-local browsing state (reset on close) export const eventSearchAtom = atom("") export const selectedCatalogEventAtom = atom(null) + +// --------------------------------------------------------------------------- +// Subscription drawer state — create (no id) or edit (existing subscription id) +// --------------------------------------------------------------------------- + +export interface SubscriptionDrawerState { + // Edit mode when set; create mode otherwise. + subscriptionId?: string + // Optional create-mode prefill from a chosen connection. + connectionId?: string + integrationKey?: string + integrationName?: string +} +export const subscriptionDrawerAtom = atom(null) + +// Deliveries drawer state — opened against one subscription. +export interface DeliveriesDrawerState { + subscriptionId: string + subscriptionName?: string +} +export const deliveriesDrawerAtom = atom(null) diff --git a/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts index 6d177ea88d..d5f81c8210 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts @@ -1,2 +1,8 @@ -export {eventsDrawerAtom, eventSearchAtom, selectedCatalogEventAtom} from "./atoms" -export type {EventsDrawerState} from "./atoms" +export { + deliveriesDrawerAtom, + eventsDrawerAtom, + eventSearchAtom, + selectedCatalogEventAtom, + subscriptionDrawerAtom, +} from "./atoms" +export type {DeliveriesDrawerState, EventsDrawerState, SubscriptionDrawerState} from "./atoms" diff --git a/web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts b/web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts index 50034bec1c..faad94054c 100644 --- a/web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts +++ b/web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts @@ -32,10 +32,14 @@ vi.mock("jotai", async (importOriginal) => { }) import { + createTriggerSubscription, + fetchTriggerSubscription, fetchTriggerEvent, fetchTriggerEvents, fetchTriggerProviders, queryTriggerConnections, + queryTriggerDeliveries, + queryTriggerSubscriptions, } from "../../src/gatewayTrigger/api/api" beforeEach(() => { @@ -171,3 +175,108 @@ describe("connections (F2 — shared rows)", () => { expect(res).toEqual({count: 0, connections: []}) }) }) + +describe("subscriptions", () => { + const sampleSubscription = { + id: "sub-1", + name: "Star watch", + connection_id: "conn-1", + enabled: true, + valid: true, + data: { + event_key: "github_star_added_event", + ti_id: "ti_abc", + trigger_config: {owner: "agenta", repo: "agenta"}, + inputs_fields: {message: "{{event.data.action}}"}, + references: {workflow_revision: {id: "rev-1"}}, + }, + } + + it("creates a subscription with the {subscription} envelope and project scope", async () => { + post.mockResolvedValueOnce({data: {count: 1, subscription: sampleSubscription}}) + + const res = await createTriggerSubscription({ + name: "Star watch", + connection_id: "conn-1", + data: { + event_key: "github_star_added_event", + inputs_fields: {message: "{{event.data.action}}"}, + references: {workflow_revision: {id: "rev-1"}}, + }, + }) + + const [url, body, opts] = post.mock.calls[0] + expect(url).toBe("https://api.test/triggers/subscriptions/") + expect(body.subscription.connection_id).toBe("conn-1") + expect(body.subscription.data.references.workflow_revision.id).toBe("rev-1") + expect(opts.params).toMatchObject({project_id: "proj-42"}) + expect(res.subscription?.id).toBe("sub-1") + }) + + it("queries subscriptions and passes the filter under {subscription}", async () => { + post.mockResolvedValueOnce({data: {count: 1, subscriptions: [sampleSubscription]}}) + + const res = await queryTriggerSubscriptions({connection_id: "conn-1"}) + + const [url, body] = post.mock.calls[0] + expect(url).toBe("https://api.test/triggers/subscriptions/query") + expect(body).toEqual({subscription: {connection_id: "conn-1"}}) + expect(res.subscriptions).toHaveLength(1) + expect(res.subscriptions[0].data.event_key).toBe("github_star_added_event") + }) + + it("fetches a single subscription by id", async () => { + get.mockResolvedValueOnce({data: {count: 1, subscription: sampleSubscription}}) + + const res = await fetchTriggerSubscription("sub-1") + + const [url, opts] = get.mock.calls[0] + expect(url).toBe("https://api.test/triggers/subscriptions/sub-1") + expect(opts.params).toMatchObject({project_id: "proj-42"}) + expect(res.subscription?.connection_id).toBe("conn-1") + }) + + it("falls back to an empty list when the subscriptions payload fails validation", async () => { + post.mockResolvedValueOnce({data: {subscriptions: "nope"}}) + + const res = await queryTriggerSubscriptions() + + expect(res).toEqual({count: 0, subscriptions: []}) + }) +}) + +describe("deliveries (read-only)", () => { + it("queries deliveries for a subscription under the {delivery} envelope", async () => { + post.mockResolvedValueOnce({ + data: { + count: 1, + deliveries: [ + { + id: "del-1", + subscription_id: "sub-1", + event_id: "evt-123", + status: {type: "success", code: "200", timestamp: "2026-06-18T00:00:00Z"}, + data: {event_key: "github_star_added_event", result: {ok: true}}, + }, + ], + }, + }) + + const res = await queryTriggerDeliveries({subscription_id: "sub-1"}) + + const [url, body, opts] = post.mock.calls[0] + expect(url).toBe("https://api.test/triggers/deliveries/query") + expect(body).toEqual({delivery: {subscription_id: "sub-1"}}) + expect(opts.params).toMatchObject({project_id: "proj-42"}) + expect(res.deliveries[0].event_id).toBe("evt-123") + expect(res.deliveries[0].status.type).toBe("success") + }) + + it("falls back to an empty list when the deliveries payload fails validation", async () => { + post.mockResolvedValueOnce({data: {deliveries: 7}}) + + const res = await queryTriggerDeliveries() + + expect(res).toEqual({count: 0, deliveries: []}) + }) +}) diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx new file mode 100644 index 0000000000..aefa936709 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerDeliveriesDrawer.tsx @@ -0,0 +1,162 @@ +import {useMemo} from "react" + +import { + deliveriesDrawerAtom, + useTriggerDeliveries, + type TriggerDelivery, +} from "@agenta/entities/gatewayTrigger" +import {Editor} from "@agenta/ui/editor" +import {Alert, Drawer, Empty, Spin, Table, Tag, Tooltip, Typography} from "antd" +import type {ColumnsType} from "antd/es/table" +import {useAtom} from "jotai" + +// --------------------------------------------------------------------------- +// TriggerDeliveriesDrawer — read-only delivery history for one subscription. +// +// One audit row per inbound event dispatched to the bound workflow: status, +// event_id, result/error, timestamps. The inbound dual of webhook deliveries. +// --------------------------------------------------------------------------- + +function statusColor(type?: string | null): string { + switch ((type ?? "").toLowerCase()) { + case "success": + case "delivered": + case "ok": + return "green" + case "error": + case "failed": + case "failure": + return "red" + case "pending": + case "running": + return "blue" + default: + return "default" + } +} + +export default function TriggerDeliveriesDrawer() { + const [state, setState] = useAtom(deliveriesDrawerAtom) + const open = !!state + + const {deliveries, isLoading} = useTriggerDeliveries(state?.subscriptionId) + + const columns: ColumnsType = useMemo( + () => [ + { + title: "Status", + key: "status", + onHeaderCell: () => ({style: {minWidth: 120}}), + render: (_, record) => { + const type = record.status?.type ?? record.status?.code + return ( + + {type ?? "unknown"} + + ) + }, + }, + { + title: "Event ID", + dataIndex: "event_id", + key: "event_id", + onHeaderCell: () => ({style: {minWidth: 180}}), + render: (value: string) => ( + + {value} + + ), + }, + { + title: "Result", + key: "result", + onHeaderCell: () => ({style: {minWidth: 200}}), + render: (_, record) => { + if (record.data?.error) { + return ( + + {record.data.error} + + ) + } + const result = record.data?.result + if (!result || Object.keys(result).length === 0) { + return - + } + return ( + + {JSON.stringify(result)} + + ) + }, + }, + { + title: "When", + key: "timestamp", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => { + const ts = record.status?.timestamp ?? record.created_at + return ( + + {ts ? new Date(ts).toLocaleString() : "-"} + + ) + }, + }, + ], + [], + ) + + return ( + setState(null)} + title={`Deliveries${state?.subscriptionName ? ` · ${state.subscriptionName}` : ""}`} + width={720} + destroyOnClose + > + {isLoading ? ( +
+ +
+ ) : ( + + columns={columns} + dataSource={deliveries} + rowKey={(record) => record.id ?? record.event_id} + bordered + size="small" + pagination={false} + locale={{emptyText: }} + expandable={{ + expandedRowRender: (record) => + record.data?.error ? ( + + ) : ( +
+ +
+ ), + rowExpandable: (record) => !!record.data?.result || !!record.data?.error, + }} + /> + )} +
+ ) +} diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx new file mode 100644 index 0000000000..edb631f5d7 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx @@ -0,0 +1,340 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from "react" + +import { + subscriptionDrawerAtom, + useTriggerConnectionsQuery, + useTriggerEvent, + useTriggerSubscription, + type TriggerConnection, + type TriggerSubscriptionCreate, + type TriggerSubscriptionData, + type TriggerSubscriptionEdit, +} from "@agenta/entities/gatewayTrigger" +import {Editor} from "@agenta/ui/editor" +import {Lightning} from "@phosphor-icons/react" +import {Button, Divider, Drawer, Form, Input, Select, Spin, Switch, Typography, message} from "antd" +import {useAtom} from "jotai" + +import SchemaForm, {type SchemaFormHandle} from "../../gatewayTool/components/SchemaForm" +import { + EntityPicker, + workflowRevisionAdapter, + type WorkflowRevisionSelectionResult, +} from "../../selection" + +const DEFAULT_PROVIDER = "composio" + +// --------------------------------------------------------------------------- +// TriggerSubscriptionDrawer (root) — create or edit a subscription. +// +// Binds a provider event (catalog) on a connected integration to a workflow +// revision. Edits are full-PUT: the body is sourced from the freshly-fetched +// subscription and only owned fields are overridden. +// --------------------------------------------------------------------------- + +export default function TriggerSubscriptionDrawer() { + const [state, setState] = useAtom(subscriptionDrawerAtom) + const open = !!state + const isEdit = !!state?.subscriptionId + + const handleClose = useCallback(() => setState(null), [setState]) + + return ( + + {state && ( + + )} + + ) +} + +// --------------------------------------------------------------------------- +// Subscription form +// --------------------------------------------------------------------------- + +function SubscriptionForm({onClose}: {onClose: () => void}) { + const [state] = useAtom(subscriptionDrawerAtom) + const subscriptionId = state?.subscriptionId + const isEdit = !!subscriptionId + + const {connections, isLoading: connectionsLoading} = useTriggerConnectionsQuery() + const { + subscription, + isLoading: subLoading, + isMutating, + create, + edit, + } = useTriggerSubscription(subscriptionId) + + const [name, setName] = useState("") + const [connectionId, setConnectionId] = useState(state?.connectionId) + const [eventKey, setEventKey] = useState("") + const [enabled, setEnabled] = useState(true) + const [workflowRevId, setWorkflowRevId] = useState(null) + const [workflowLabel, setWorkflowLabel] = useState(null) + const [inputsText, setInputsText] = useState("{}") + const [inputsError, setInputsError] = useState(null) + + const [configForm] = Form.useForm() + const configFormRef = useRef(null) + + // Prefill from the freshly-fetched subscription (edit mode). + useEffect(() => { + if (!isEdit || !subscription) return + setName(subscription.name ?? "") + setConnectionId(subscription.connection_id) + setEventKey(subscription.data?.event_key ?? "") + setEnabled(subscription.enabled ?? true) + const wfId = subscription.data?.references?.workflow_revision?.id ?? null + setWorkflowRevId(wfId) + setWorkflowLabel(wfId) + setInputsText(JSON.stringify(subscription.data?.inputs_fields ?? {}, null, 2)) + }, [isEdit, subscription]) + + const selectedConnection = useMemo( + () => connections.find((c) => c.id === connectionId), + [connections, connectionId], + ) + + const integrationKey = selectedConnection?.integration_key ?? "" + + // trigger_config schema for the chosen event (catalog detail). + const {event: eventDetail, isLoading: eventLoading} = useTriggerEvent(integrationKey, eventKey) + const triggerConfigSchema = (eventDetail?.trigger_config ?? null) as Record< + string, + unknown + > | null + + // Seed the config form with existing trigger_config on edit. + useEffect(() => { + if (isEdit && subscription?.data?.trigger_config) { + configForm.setFieldsValue(subscription.data.trigger_config) + } + }, [isEdit, subscription, configForm]) + + const handleSubmit = useCallback(async () => { + if (!connectionId) { + message.error("Select a connection") + return + } + if (!eventKey) { + message.error("Select an event") + return + } + if (!workflowRevId) { + message.error("Bind a workflow") + return + } + + let inputsFields: Record = {} + try { + inputsFields = inputsText.trim() ? JSON.parse(inputsText) : {} + setInputsError(null) + } catch { + setInputsError("Invalid JSON") + message.error("inputs mapping is not valid JSON") + return + } + + let triggerConfig: Record | undefined + try { + triggerConfig = (await configFormRef.current?.getValues()) ?? undefined + } catch { + // form validation failed + return + } + + const data: TriggerSubscriptionData = { + event_key: eventKey, + trigger_config: triggerConfig, + inputs_fields: inputsFields, + references: {workflow_revision: {id: workflowRevId}}, + } + + try { + if (isEdit && subscription) { + // Full PUT — carry the whole entity, override owned fields. + const body: TriggerSubscriptionEdit = { + id: subscription.id as string, + name: name || null, + description: subscription.description ?? null, + flags: subscription.flags ?? null, + tags: subscription.tags ?? null, + meta: subscription.meta ?? null, + connection_id: connectionId, + data: {...subscription.data, ...data}, + enabled, + valid: subscription.valid ?? true, + } + const result = await edit(body) + if (!result) { + message.error("Failed to update subscription") + return + } + message.success("Subscription updated") + } else { + const body: TriggerSubscriptionCreate = { + name: name || null, + connection_id: connectionId, + data, + } + const result = await create(body) + if (!result) { + message.error("Failed to create subscription") + return + } + message.success("Subscription created") + } + onClose() + } catch { + message.error("Failed to save subscription") + } + }, [ + connectionId, + eventKey, + workflowRevId, + inputsText, + isEdit, + subscription, + name, + enabled, + edit, + create, + onClose, + ]) + + if (isEdit && subLoading) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+ + setName(e.target.value)} + /> + + + + } + value={eventKey} + onChange={(e) => setEventKey(e.target.value)} + disabled={!connectionId} + /> + + Provider: {DEFAULT_PROVIDER} + {integrationKey ? ` · ${integrationKey}` : ""} + + + + +
+ + variant="popover-cascader" + adapter={workflowRevisionAdapter} + onSelect={(selection) => { + setWorkflowRevId(selection.id) + setWorkflowLabel(selection.label) + }} + size="small" + placeholder={workflowLabel ?? "Select workflow revision"} + /> + {workflowLabel && ( + + {workflowLabel} + + )} +
+
+ + + + + Trigger configuration + +
+ {eventLoading ? ( +
+ +
+ ) : ( + + )} +
+ + +
+ setInputsText(textContent)} + codeOnly + showToolbar={false} + language="json" + dimensions={{width: "100%", height: 120}} + disabled={isMutating} + /> +
+
+ + + + + +
+ + + +
+ + +
+
+ ) +} diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts b/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts index e7b4348ef1..4ea0d0551d 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts @@ -8,3 +8,5 @@ */ export {default as TriggerEventsDrawer} from "./drawers/TriggerEventsDrawer" +export {default as TriggerSubscriptionDrawer} from "./drawers/TriggerSubscriptionDrawer" +export {default as TriggerDeliveriesDrawer} from "./drawers/TriggerDeliveriesDrawer"