diff --git a/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts b/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts index 14a42fe341..e7be0995c2 100644 --- a/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts +++ b/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts @@ -41,6 +41,9 @@ export interface SubscriptionDrawerState { // `data.references` (`application`/`application_variant`); each value is a // `{id}` reference. Ignored in edit mode. defaultReferences?: Record + // Optional create-mode metadata to persist with the trigger. Used by scoped + // playground entrypoints to remember the revision where the trigger was added. + defaultMeta?: Record | null // Human-readable label for `defaultReferences` (e.g. the agent's name), so // the bound-workflow field shows a name instead of a raw id. Ignored in edit mode. defaultBoundLabel?: string @@ -63,6 +66,9 @@ export interface ScheduleDrawerState { // (`application`/`application_variant`); each value is a `{id}` reference. // Ignored in edit mode. defaultReferences?: Record + // Optional create-mode metadata to persist with the trigger. Used by scoped + // playground entrypoints to remember the revision where the trigger was added. + defaultMeta?: Record | null // Human-readable label for `defaultReferences` (e.g. the agent's name), so // the bound-workflow field shows a name instead of a raw id. Ignored in edit mode. defaultBoundLabel?: string diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx index 31b7ec7ed3..eb821073d4 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx @@ -31,7 +31,6 @@ import { useTriggerSchedules, useTriggerSubscription, useTriggerSubscriptions, - type TriggerReference, type TriggerSchedule, type TriggerSubscription, } from "@agenta/entities/gatewayTrigger" @@ -69,6 +68,12 @@ import TriggerSubscriptionDrawer from "../../gatewayTrigger/drawers/TriggerSubsc import {AddTextLink} from "./AddTextLink" import {CollapsibleProviderGroup, SubSectionHeader} from "./sectionGroups" +import { + addAgentTriggerOriginMeta, + agentTriggerMatchesContext, + buildAgentTriggerOrigin, + getAgentTriggerContext, +} from "./triggerScope" // Persisted per-agent expand state for provider groups (key = `${entityId}:${providerKey}`). const triggerGroupsExpandedAtom = atomWithStorage>( @@ -282,18 +287,6 @@ export interface TriggerManagementSectionProps { disabled?: boolean } -/** Whether any id in a trigger's `data.references` matches one of the agent's ids. */ -function referencesMatch( - references: Record | null | undefined, - agentIds: Set, -): boolean { - if (!references || agentIds.size === 0) return false - for (const ref of Object.values(references)) { - if (ref?.id && agentIds.has(ref.id)) return true - } - return false -} - /** * Resolve the agent's matchable ids, its `defaultReferences`, and the project triggers * scoped to it. Shared by the section body and the header count badge / add-dropdown in @@ -307,6 +300,11 @@ export function useAgentTriggers(entityId: string | null) { ) const appId = revision?.workflow_id ?? null const variantId = revision?.workflow_variant_id ?? revision?.variant_id ?? null + const triggerContext = useMemo( + () => getAgentTriggerContext(entityId, revision), + [entityId, revision], + ) + const triggerOrigin = useMemo(() => buildAgentTriggerOrigin(triggerContext), [triggerContext]) // The app slug — needed for "By environment" binding, which resolves via the // application slug + environment (see triggers/service.py `_normalize_references`). const appSlug = (revision as {slug?: string} | null)?.slug ?? null @@ -339,17 +337,18 @@ export function useAgentTriggers(entityId: string | null) { const {schedules} = useTriggerSchedules() const scopedSubscriptions = useMemo( - () => subscriptions.filter((s) => referencesMatch(s.data?.references, agentIds)), - [subscriptions, agentIds], + () => subscriptions.filter((s) => agentTriggerMatchesContext(s, triggerContext, agentIds)), + [subscriptions, triggerContext, agentIds], ) const scopedSchedules = useMemo( - () => schedules.filter((s) => referencesMatch(s.data?.references, agentIds)), - [schedules, agentIds], + () => schedules.filter((s) => agentTriggerMatchesContext(s, triggerContext, agentIds)), + [schedules, triggerContext, agentIds], ) return { defaultReferences, defaultBoundLabel, + defaultMeta: addAgentTriggerOriginMeta(null, triggerOrigin), scopedSubscriptions, scopedSchedules, count: scopedSubscriptions.length + scopedSchedules.length, @@ -471,8 +470,14 @@ function TriggerRow({ } export function TriggerManagementSection({entityId, disabled}: TriggerManagementSectionProps) { - const {scopedSubscriptions, scopedSchedules, count, defaultReferences, defaultBoundLabel} = - useAgentTriggers(entityId) + const { + scopedSubscriptions, + scopedSchedules, + count, + defaultReferences, + defaultBoundLabel, + defaultMeta, + } = useAgentTriggers(entityId) const {connections} = useTriggerConnectionsQuery() const {integrations} = useTriggerCatalogIntegrations() @@ -742,6 +747,7 @@ export function TriggerManagementSection({entityId, disabled}: TriggerManagement ? () => openSubscriptionDrawer({ defaultReferences, + defaultMeta, defaultBoundLabel, playgroundEntityId: entityId ?? undefined, integrationKey: group.key, @@ -869,7 +875,7 @@ export function AddTriggerDropdown({ /** Custom trigger element (e.g. an inline text-link for an empty state). Defaults to a `+`. */ trigger?: ReactNode }) { - const {defaultReferences, defaultBoundLabel} = useAgentTriggers(entityId) + const {defaultReferences, defaultBoundLabel, defaultMeta} = useAgentTriggers(entityId) const openSubscriptionDrawer = useSetAtom(triggerSubscriptionDrawerAtom) const openScheduleDrawer = useSetAtom(triggerScheduleDrawerAtom) @@ -890,6 +896,7 @@ export function AddTriggerDropdown({ onSelect: () => openSubscriptionDrawer({ defaultReferences, + defaultMeta, defaultBoundLabel, playgroundEntityId: entityId ?? undefined, }), @@ -903,6 +910,7 @@ export function AddTriggerDropdown({ onSelect: () => openScheduleDrawer({ defaultReferences, + defaultMeta, defaultBoundLabel, playgroundEntityId: entityId ?? undefined, }), diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/triggerScope.ts b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/triggerScope.ts new file mode 100644 index 0000000000..28b2904509 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/triggerScope.ts @@ -0,0 +1,126 @@ +import type {TriggerReference} from "@agenta/entities/gatewayTrigger" +import type {Workflow} from "@agenta/entities/workflow" + +export const AGENT_TRIGGER_ORIGIN_META_KEY = "agenta_agent_trigger_origin" + +export interface AgentTriggerOrigin { + revision_id: string + variant_id?: string | null + revision_version?: number | null +} + +export interface AgentTriggerContext { + revisionId: string | null + variantId: string | null + revisionVersion: number | null +} + +export interface AgentTriggerEntity { + data?: { + references?: Record | null + } | null + meta?: Record | null +} + +function finiteNumber(value: unknown): number | null { + const number = typeof value === "number" ? value : Number(value) + return Number.isFinite(number) ? number : null +} + +export function getAgentTriggerContext( + entityId: string | null, + revision: Workflow | null | undefined, +): AgentTriggerContext { + return { + revisionId: entityId, + variantId: revision?.workflow_variant_id ?? revision?.variant_id ?? null, + revisionVersion: finiteNumber(revision?.version), + } +} + +export function buildAgentTriggerOrigin(context: AgentTriggerContext): AgentTriggerOrigin | null { + if (!context.revisionId) return null + return { + revision_id: context.revisionId, + variant_id: context.variantId, + revision_version: context.revisionVersion, + } +} + +export function addAgentTriggerOriginMeta( + meta: Record | null | undefined, + origin: AgentTriggerOrigin | null, +): Record | null { + if (!origin) return meta ?? null + return { + ...(meta ?? {}), + [AGENT_TRIGGER_ORIGIN_META_KEY]: origin, + } +} + +function readAgentTriggerOrigin( + meta: Record | null | undefined, +): AgentTriggerOrigin | null { + const raw = meta?.[AGENT_TRIGGER_ORIGIN_META_KEY] + if (!raw || typeof raw !== "object") return null + const value = raw as Record + const revisionId = typeof value.revision_id === "string" ? value.revision_id : null + if (!revisionId) return null + return { + revision_id: revisionId, + variant_id: typeof value.variant_id === "string" ? value.variant_id : null, + revision_version: finiteNumber(value.revision_version), + } +} + +/** Whether any id in a trigger's `data.references` matches one of the agent's ids. */ +function referencesMatch( + references: Record | null | undefined, + agentIds: Set, +): boolean { + if (!references || agentIds.size === 0) return false + for (const ref of Object.values(references)) { + if (ref?.id && agentIds.has(ref.id)) return true + } + return false +} + +function revisionReferenceMatches( + references: Record | null | undefined, + revisionId: string | null, +): boolean { + if (!references || !revisionId) return false + return ( + references.application_revision?.id === revisionId || + references.workflow_revision?.id === revisionId || + references.evaluator_revision?.id === revisionId + ) +} + +// Origin-scoped triggers show on the revision they were created on and any later +// revision of the same variant; never on earlier revisions or sibling variants. +function originMatchesContext(origin: AgentTriggerOrigin, context: AgentTriggerContext): boolean { + if (origin.revision_id === context.revisionId) return true + if (!origin.variant_id || origin.variant_id !== context.variantId) return false + if (origin.revision_version == null || context.revisionVersion === null) return false + return context.revisionVersion >= origin.revision_version +} + +export function agentTriggerMatchesContext( + trigger: AgentTriggerEntity, + context: AgentTriggerContext, + agentIds: Set, +): boolean { + const references = trigger.data?.references + const origin = readAgentTriggerOrigin(trigger.meta) + + if (origin) { + return originMatchesContext(origin, context) + } + + if (revisionReferenceMatches(references, context.revisionId)) { + return true + } + + return referencesMatch(references, agentIds) +} diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx index 30de24a110..49aa1558c0 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerScheduleDrawer.tsx @@ -654,6 +654,7 @@ function ScheduleForm({ } else { const body: TriggerScheduleCreate = { name: name || null, + meta: state?.defaultMeta ?? null, data, // Honor the Active toggle at creation (otherwise the BE defaults to active). flags: {is_active: enabled}, @@ -688,6 +689,7 @@ function ScheduleForm({ schedule, name, enabled, + state?.defaultMeta, edit, create, onClose, diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx index e0690191c8..fababe91c9 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerSubscriptionDrawer.tsx @@ -745,6 +745,7 @@ function SubscriptionForm({ } else { const body: TriggerSubscriptionCreate = { name: name || null, + meta: state?.defaultMeta ?? null, connection_id: connectionId, data, // Honor the Active toggle at creation (BE defaults to active; is_valid @@ -771,6 +772,7 @@ function SubscriptionForm({ subscription, name, enabled, + state?.defaultMeta, edit, create, onClose, diff --git a/web/packages/agenta-entity-ui/tests/unit/triggerScope.test.ts b/web/packages/agenta-entity-ui/tests/unit/triggerScope.test.ts new file mode 100644 index 0000000000..20b687aecb --- /dev/null +++ b/web/packages/agenta-entity-ui/tests/unit/triggerScope.test.ts @@ -0,0 +1,127 @@ +import {describe, expect, it} from "vitest" + +import { + AGENT_TRIGGER_ORIGIN_META_KEY, + addAgentTriggerOriginMeta, + agentTriggerMatchesContext, + buildAgentTriggerOrigin, + type AgentTriggerContext, + type AgentTriggerEntity, +} from "../../src/DrillInView/SchemaControls/triggerScope" + +const ids = new Set(["app-1", "variant-a", "rev-2"]) + +const context: AgentTriggerContext = { + revisionId: "rev-2", + variantId: "variant-a", + revisionVersion: 2, +} + +function trigger({ + refs, + meta, +}: { + refs?: AgentTriggerEntity["data"]["references"] + meta?: AgentTriggerEntity["meta"] +}): AgentTriggerEntity { + return { + data: { + event_key: "schedule.tick", + references: refs, + }, + meta: meta ?? {}, + } +} + +describe("agentTriggerMatchesContext", () => { + it("keeps legacy variant-bound triggers visible anywhere in the variant", () => { + expect( + agentTriggerMatchesContext( + trigger({refs: {application_variant: {id: "variant-a"}}}), + context, + ids, + ), + ).toBe(true) + }) + + it("shows origin-scoped triggers on the origin revision and later revisions in the same variant", () => { + const meta = addAgentTriggerOriginMeta(null, { + revision_id: "rev-1", + variant_id: "variant-a", + revision_version: 1, + }) + + expect( + agentTriggerMatchesContext( + trigger({refs: {application_variant: {id: "variant-a"}}, meta}), + context, + ids, + ), + ).toBe(true) + }) + + it("hides origin-scoped triggers from older revisions in the same variant", () => { + const meta = addAgentTriggerOriginMeta(null, { + revision_id: "rev-3", + variant_id: "variant-a", + revision_version: 3, + }) + + expect( + agentTriggerMatchesContext( + trigger({refs: {application_variant: {id: "variant-a"}}, meta}), + context, + ids, + ), + ).toBe(false) + }) + + it("hides origin-scoped triggers from sibling variants", () => { + const meta = addAgentTriggerOriginMeta(null, { + revision_id: "rev-1", + variant_id: "variant-b", + revision_version: 1, + }) + + expect( + agentTriggerMatchesContext( + trigger({refs: {application_variant: {id: "variant-b"}}, meta}), + context, + ids, + ), + ).toBe(false) + }) + + it("still supports exact revision references without origin metadata", () => { + expect( + agentTriggerMatchesContext( + trigger({refs: {application_revision: {id: "rev-2"}}}), + context, + ids, + ), + ).toBe(true) + }) +}) + +describe("buildAgentTriggerOrigin", () => { + it("stores the revision where the trigger was created", () => { + expect(buildAgentTriggerOrigin(context)).toEqual({ + revision_id: "rev-2", + variant_id: "variant-a", + revision_version: 2, + }) + }) + + it("merges the origin marker into existing metadata", () => { + expect( + addAgentTriggerOriginMeta({source: "playground"}, buildAgentTriggerOrigin(context)), + ).toEqual({ + source: "playground", + [AGENT_TRIGGER_ORIGIN_META_KEY]: { + revision_id: "rev-2", + variant_id: "variant-a", + revision_version: 2, + }, + }) + }) +})