diff --git a/.context/admin/orchestration-webhooks.md b/.context/admin/orchestration-webhooks.md index eeb7650ef..0d663f364 100644 --- a/.context/admin/orchestration-webhooks.md +++ b/.context/admin/orchestration-webhooks.md @@ -45,7 +45,7 @@ which destination fields are used. `components/admin/orchestration/webhooks-table.tsx` -- Table columns: URL (truncated + description), events (badges, max 3 + overflow count), delivery count, active Switch, created date, row actions dropdown (Edit, Delete) +- Table columns: URL (truncated + description), events (badges, max 3 + overflow count, plus a `Scoped` badge when the row has any `agentIds` / `workflowIds` set), delivery count, active Switch, created date, row actions dropdown (Edit, Delete) - Active filter dropdown, pagination - Inline active/inactive toggle via `Switch` — optimistic update with revert on failure - Row actions dropdown with Edit (navigates to edit page) and Delete (AlertDialog confirmation) @@ -59,6 +59,7 @@ which destination fields are used. - Signing secret input with auto-generate, reveal/hide eye toggle, and clipboard-copy buttons. Generating a secret auto-reveals it so the user can capture it before saving. While the field has a value, an amber notice reminds the user to copy now — Sunrise never returns the secret again after save (the API's `SAFE_SELECT` strips it from every GET). - 12 event checkboxes from `WEBHOOK_EVENT_TYPES` (including `execution_crashed` for engine-crash alerts — see [Hooks](../orchestration/hooks.md#event-types)) - Description textarea +- **Scope block** (between Events and Retry policy): two async-search `MultiSelect`s — "Limit to agents" and "Limit to workflows". Each multi-selects from the matching admin list endpoint (`?q=` server-side search, 50-row page, names rendered on chips via a pre-fetch). Both default to empty = "all agents / all workflows". Cap: 50 entries per dimension. Filters apply **dimension-specifically** (see [Entity-Scoped Subscriptions](../orchestration/hooks.md#entity-scoped-subscriptions)) — an agent filter does not restrict workflow-typed events and vice versa. - Retry policy block: `maxAttempts` (1–10) and `retryBackoffSeconds` (comma-separated seconds, each 1–86400). Form input is seconds; API field is `retryBackoffMs` (millisecond array). Defaults: 3 attempts with `10, 60, 300` seconds. The form blocks submit unless the array has at least `maxAttempts - 1` entries. - Active toggle - In edit mode, empty secret field = keep current secret diff --git a/.context/orchestration/hooks.md b/.context/orchestration/hooks.md index 79ed4da36..b464bcbb8 100644 --- a/.context/orchestration/hooks.md +++ b/.context/orchestration/hooks.md @@ -355,6 +355,35 @@ template that covers any update event: event from a flapping provider until the breaker actually cycles. Payload: `{ providerSlug, failures, threshold, windowMs, cooldownMs, openedAt }`. +## Entity-Scoped Subscriptions + +`AiWebhookSubscription` rows carry two optional filter arrays — +`agentIds` and `workflowIds` — that let admins narrow a subscription +to specific entities. Empty array means "no constraint on that +dimension" (backward compatible). + +**Rules** (centralised in +`lib/orchestration/webhooks/event-entity-keys.ts`): + +- **Dimension-specific.** Each filter only constrains events that + carry an ID in its dimension. A sub with `agentIds=['x']` still + receives every `workflow_failed` event — the agent filter doesn't + apply to workflow-typed events. Use the workflow filter to scope + those. +- **Unscopable events** (like `circuit_breaker_opened`) fire for + every matching sub regardless of filters — there's no entity + dimension to filter on. +- **Fail-closed.** If a mapped event's payload is missing the + expected ID (an enrichment bug at the dispatch site), a scoped + sub with a non-empty filter on that dimension does NOT match. + Prevents leaks; the event still reaches unscoped subs. + +The `EVENT_ENTITY_KEYS` map is the single source of truth for +which payload field carries each entity ID. Adding a new wired +event type requires adding a row there (or an explicit `{}` to +declare "no scopable entity"); the `EVENT_ENTITY_KEYS map coverage` +test fails otherwise. + ## Related Docs - [Webhook Management UI](../admin/orchestration-webhooks.md) — the separate HMAC-signed outbound webhook subsystem diff --git a/app/admin/orchestration/event-subscriptions/[id]/page.tsx b/app/admin/orchestration/event-subscriptions/[id]/page.tsx index 68872d86c..599363117 100644 --- a/app/admin/orchestration/event-subscriptions/[id]/page.tsx +++ b/app/admin/orchestration/event-subscriptions/[id]/page.tsx @@ -20,6 +20,8 @@ interface WebhookDetail { url: string | null; emailAddress: string | null; events: string[]; + agentIds: string[]; + workflowIds: string[]; isActive: boolean; description: string | null; maxAttempts: number; diff --git a/app/api/v1/admin/orchestration/webhooks/[id]/route.ts b/app/api/v1/admin/orchestration/webhooks/[id]/route.ts index d28601e1e..f71ed003d 100644 --- a/app/api/v1/admin/orchestration/webhooks/[id]/route.ts +++ b/app/api/v1/admin/orchestration/webhooks/[id]/route.ts @@ -27,6 +27,8 @@ const SAFE_SELECT = { url: true, emailAddress: true, events: true, + agentIds: true, + workflowIds: true, isActive: true, description: true, maxAttempts: true, diff --git a/app/api/v1/admin/orchestration/webhooks/route.ts b/app/api/v1/admin/orchestration/webhooks/route.ts index 84b99502a..3f2ca00b3 100644 --- a/app/api/v1/admin/orchestration/webhooks/route.ts +++ b/app/api/v1/admin/orchestration/webhooks/route.ts @@ -40,6 +40,8 @@ export const GET = withAdminAuth(async (request, session) => { url: true, emailAddress: true, events: true, + agentIds: true, + workflowIds: true, isActive: true, description: true, createdAt: true, @@ -72,6 +74,8 @@ export const POST = withAdminAuth(async (request, session) => { isActive: body.isActive ?? true, maxAttempts: body.maxAttempts, retryBackoffMs: body.retryBackoffMs, + agentIds: body.agentIds ?? [], + workflowIds: body.workflowIds ?? [], createdBy: session.user.id, }; if (body.channel === 'webhook') { @@ -89,6 +93,8 @@ export const POST = withAdminAuth(async (request, session) => { url: true, emailAddress: true, events: true, + agentIds: true, + workflowIds: true, isActive: true, description: true, maxAttempts: true, diff --git a/components/admin/orchestration/webhook-form.tsx b/components/admin/orchestration/webhook-form.tsx index 10a5e0a2c..2f004cdc8 100644 --- a/components/admin/orchestration/webhook-form.tsx +++ b/components/admin/orchestration/webhook-form.tsx @@ -10,7 +10,7 @@ * Follows the agent-form pattern: react-hook-form + zodResolver. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useForm, type Resolver } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter } from 'next/navigation'; @@ -33,6 +33,7 @@ import { Button } from '@/components/ui/button'; import { FieldHelp } from '@/components/ui/field-help'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { MultiSelect, type MultiSelectOption } from '@/components/ui/multi-select'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { apiClient, APIClientError } from '@/lib/api/client'; @@ -50,6 +51,8 @@ import { const commonFields = { channel: z.enum(['webhook', 'email']), events: z.array(z.string()).min(1, 'Select at least one event'), + agentIds: z.array(z.string()).max(50, 'At most 50 agents per subscription'), + workflowIds: z.array(z.string()).max(50, 'At most 50 workflows per subscription'), description: z.string().max(500).optional(), isActive: z.boolean(), maxAttempts: z @@ -164,6 +167,8 @@ export interface WebhookFormProps { url: string | null; emailAddress: string | null; events: string[]; + agentIds: string[]; + workflowIds: string[]; isActive: boolean; description: string | null; maxAttempts: number; @@ -217,6 +222,8 @@ export function WebhookForm({ mode, webhook }: WebhookFormProps) { secret: '', emailAddress: webhook?.emailAddress ?? '', events: webhook?.events ?? [], + agentIds: webhook?.agentIds ?? [], + workflowIds: webhook?.workflowIds ?? [], description: webhook?.description ?? '', isActive: webhook?.isActive ?? true, maxAttempts: webhook?.maxAttempts ?? 3, @@ -228,8 +235,102 @@ export function WebhookForm({ mode, webhook }: WebhookFormProps) { const currentEvents = watch('events'); const currentIsActive = watch('isActive'); const currentSecret = watch('secret'); + const currentAgentIds = watch('agentIds'); + const currentWorkflowIds = watch('workflowIds'); const hasSecretValue = Boolean(currentSecret && currentSecret.length > 0); + // Pre-fetch labels for any pre-selected agents/workflows so chips render + // human names instead of raw CUIDs. The async loaders below only know + // what the user types — without this lookup, edit-mode chips would show + // bare IDs until the user typed something. See knowledge-access-section.tsx. + const [selectedAgentLabels, setSelectedAgentLabels] = useState>({}); + const [selectedWorkflowLabels, setSelectedWorkflowLabels] = useState>({}); + + useEffect(() => { + if (currentAgentIds.length === 0) return; + let cancelled = false; + void (async () => { + try { + const agents = await apiClient.get>( + `${API.ADMIN.ORCHESTRATION.AGENTS}?limit=100` + ); + if (cancelled) return; + const labels: Record = {}; + for (const a of agents ?? []) { + if (currentAgentIds.includes(a.id)) labels[a.id] = a.name; + } + setSelectedAgentLabels(labels); + } catch { + // Non-fatal — chips fall back to IDs until the user searches. + } + })(); + return () => { + cancelled = true; + }; + }, [currentAgentIds]); + + useEffect(() => { + if (currentWorkflowIds.length === 0) return; + let cancelled = false; + void (async () => { + try { + const workflows = await apiClient.get>( + `${API.ADMIN.ORCHESTRATION.WORKFLOWS}?limit=100` + ); + if (cancelled) return; + const labels: Record = {}; + for (const w of workflows ?? []) { + if (currentWorkflowIds.includes(w.id)) labels[w.id] = w.name; + } + setSelectedWorkflowLabels(labels); + } catch { + // Non-fatal. + } + })(); + return () => { + cancelled = true; + }; + }, [currentWorkflowIds]); + + async function loadAgentOptions(query: string): Promise { + const url = new URL(API.ADMIN.ORCHESTRATION.AGENTS, window.location.origin); + url.searchParams.set('limit', '50'); + if (query.trim()) url.searchParams.set('q', query.trim()); + try { + const agents = await apiClient.get>( + `${url.pathname}${url.search}` + ); + return (agents ?? []).map((a) => ({ + value: a.id, + label: a.name, + description: a.slug, + })); + } catch { + return []; + } + } + + async function loadWorkflowOptions(query: string): Promise { + const url = new URL(API.ADMIN.ORCHESTRATION.WORKFLOWS, window.location.origin); + url.searchParams.set('limit', '50'); + // Hide templates — they aren't instantiated runtime entities, so they + // never appear in event payloads and scoping a sub to one is a no-op. + url.searchParams.set('isTemplate', 'false'); + if (query.trim()) url.searchParams.set('q', query.trim()); + try { + const workflows = await apiClient.get>( + `${url.pathname}${url.search}` + ); + return (workflows ?? []).map((w) => ({ + value: w.id, + label: w.name, + description: w.slug, + })); + } catch { + return []; + } + } + const copySecret = async () => { if (!currentSecret) return; setSecretCopyError(null); @@ -272,6 +373,8 @@ export function WebhookForm({ mode, webhook }: WebhookFormProps) { const payload: Record = { channel: data.channel, events: data.events, + agentIds: data.agentIds, + workflowIds: data.workflowIds, description: data.description, isActive: data.isActive, maxAttempts: data.maxAttempts, @@ -584,6 +687,63 @@ export function WebhookForm({ mode, webhook }: WebhookFormProps) { {errors.events &&

{errors.events.message}

} + {/* Entity scope */} +
+
+

Scope

+

+ Optional. Limit this subscription to specific agents or workflows. Each filter applies + only to events about that kind of entity — for example, an agent filter does not affect + workflow_failed events. +

+
+ +
+ + setValue('agentIds', next, { shouldValidate: true })} + loadOptions={loadAgentOptions} + selectedLabels={selectedAgentLabels} + placeholder="All agents" + emptyText="No matching agents." + /> + {errors.agentIds &&

{errors.agentIds.message}

} +
+ +
+ + setValue('workflowIds', next, { shouldValidate: true })} + loadOptions={loadWorkflowOptions} + selectedLabels={selectedWorkflowLabels} + placeholder="All workflows" + emptyText="No matching workflows." + /> + {errors.workflowIds && ( +

{errors.workflowIds.message}

+ )} +
+
+ {/* Retry policy */}
diff --git a/components/admin/orchestration/webhooks-table.tsx b/components/admin/orchestration/webhooks-table.tsx index 5490dc8d1..512a8814d 100644 --- a/components/admin/orchestration/webhooks-table.tsx +++ b/components/admin/orchestration/webhooks-table.tsx @@ -63,6 +63,8 @@ export interface WebhookListItem { id: string; url: string; events: string[]; + agentIds: string[]; + workflowIds: string[]; isActive: boolean; description: string | null; createdAt: string; @@ -226,6 +228,15 @@ export function WebhooksTable({ initialWebhooks, initialMeta }: WebhooksTablePro +{wh.events.length - 3} )} + {(wh.agentIds?.length ?? 0) + (wh.workflowIds?.length ?? 0) > 0 && ( + + Scoped + + )}
diff --git a/lib/orchestration/engine/events.ts b/lib/orchestration/engine/events.ts index dae1c2185..8482efc83 100644 --- a/lib/orchestration/engine/events.ts +++ b/lib/orchestration/engine/events.ts @@ -96,13 +96,15 @@ export function workflowBudgetExceeded( usedUsd: number, limitUsd: number, failedStepId: string, - executionId?: string + executionId?: string, + workflowId?: string ): ExecutionEvent { dispatchWebhookEvent('workflow_budget_exceeded', { usedUsd, limitUsd, failedStepId, ...(executionId ? { executionId } : {}), + ...(workflowId ? { workflowId } : {}), }).catch((err) => { logger.warn('Webhook dispatch failed for workflow_budget_exceeded', { failedStepId, diff --git a/lib/orchestration/engine/orchestration-engine.ts b/lib/orchestration/engine/orchestration-engine.ts index 8ef8caa6a..db448c703 100644 --- a/lib/orchestration/engine/orchestration-engine.ts +++ b/lib/orchestration/engine/orchestration-engine.ts @@ -608,7 +608,13 @@ export class OrchestrationEngine { // attribution point; fall back to the workflow's entry step // if the batch yielded no step ids (degenerate empty batch). const attribStep = batchResult.nextIds[0] ?? workflow.definition.entryStepId; - yield workflowBudgetExceeded(ctx.totalCostUsd, budgetLimitUsd, attribStep, executionId); + yield workflowBudgetExceeded( + ctx.totalCostUsd, + budgetLimitUsd, + attribStep, + executionId, + ctx.workflowId + ); yield workflowFailed(failureReason, undefined, ctx); failed = true; break; @@ -1141,7 +1147,13 @@ export class OrchestrationEngine { // the webhook subscriber path is identical regardless of which // of the four cap-check sites triggered the breach. const reason = formatBudgetExceededReason(err.usedUsd, err.limitUsd); - yield workflowBudgetExceeded(err.usedUsd, err.limitUsd, step.id, lease.executionId); + yield workflowBudgetExceeded( + err.usedUsd, + err.limitUsd, + step.id, + lease.executionId, + ctx.workflowId + ); yield workflowFailed(reason, step.id, ctx); return { failed: true, @@ -1245,7 +1257,13 @@ export class OrchestrationEngine { // intact. if (budgetLimitUsd && ctx.totalCostUsd > budgetLimitUsd) { const reason = formatBudgetExceededReason(ctx.totalCostUsd, budgetLimitUsd); - yield workflowBudgetExceeded(ctx.totalCostUsd, budgetLimitUsd, step.id, lease.executionId); + yield workflowBudgetExceeded( + ctx.totalCostUsd, + budgetLimitUsd, + step.id, + lease.executionId, + ctx.workflowId + ); yield workflowFailed(reason, step.id, ctx); return { failed: true, @@ -1617,7 +1635,13 @@ export class OrchestrationEngine { 'during parallel batch' ); allEvents.push( - workflowBudgetExceeded(ctx.totalCostUsd, budgetLimitUsd, step.id, executionId) + workflowBudgetExceeded( + ctx.totalCostUsd, + budgetLimitUsd, + step.id, + executionId, + ctx.workflowId + ) ); allEvents.push(workflowFailed(reason, step.id, ctx)); batchFailed = true; diff --git a/lib/orchestration/webhooks/dispatcher.ts b/lib/orchestration/webhooks/dispatcher.ts index 95c22cd2a..2c03b47d4 100644 --- a/lib/orchestration/webhooks/dispatcher.ts +++ b/lib/orchestration/webhooks/dispatcher.ts @@ -22,6 +22,7 @@ import { prisma } from '@/lib/db/client'; import { logger } from '@/lib/logging'; import { getResendClient, getDefaultSender, isEmailEnabled } from '@/lib/email/client'; import EventNotification from '@/emails/event-notification'; +import { matchesEntityScope } from '@/lib/orchestration/webhooks/event-entity-keys'; const DISPATCH_TIMEOUT_MS = 5000; @@ -120,6 +121,14 @@ export async function dispatchWebhookEvent( if (subscriptions.length === 0) return; + // Dimension-specific entity scoping: filter out subscriptions whose + // agent/workflow filters exclude this event. Done in JS (not SQL) + // because the admin-scoped subs list is small and the rules are + // dimension-specific — see event-entity-keys.ts for the contract. + const matched = subscriptions.filter((sub) => matchesEntityScope(eventType, payload, sub)); + + if (matched.length === 0) return; + const body = JSON.stringify({ event: eventType, timestamp: new Date().toISOString(), @@ -127,7 +136,7 @@ export async function dispatchWebhookEvent( }); await Promise.allSettled( - subscriptions.map(async (sub) => { + matched.map(async (sub) => { // Create delivery record const delivery = await prisma.aiWebhookDelivery.create({ data: { diff --git a/lib/orchestration/webhooks/event-entity-keys.ts b/lib/orchestration/webhooks/event-entity-keys.ts new file mode 100644 index 000000000..602e45565 --- /dev/null +++ b/lib/orchestration/webhooks/event-entity-keys.ts @@ -0,0 +1,93 @@ +/** + * Event → entity-payload-key map. + * + * Single source of truth for which payload field carries the agent or + * workflow identifier for a given event type. The dispatcher uses this + * to apply dimension-specific entity scoping on `AiWebhookSubscription` + * rows (see `agentIds` / `workflowIds` columns). + * + * Rules: + * - Absent map entry OR empty record → event has no scopable entity, + * scoped subs match regardless of their filters. + * - `agent` key set → dispatcher reads payload[key] + * and intersects against the subscription's `agentIds` filter. + * - `workflow` key set → same, for `workflowIds`. + * - Payload is missing the expected ID despite the event being mapped + * → fail-closed: a sub with a non-empty filter on that dimension + * does NOT match. Prevents an enrichment bug from leaking events + * to scoped subscribers. + * + * When adding a new wired event type, add a row here (or leave it out + * if the event has no scopable entity). The `EVENT_ENTITY_KEYS_COVERS_ALL` + * test asserts every entry in `WEBHOOK_EVENT_TYPES` has a deliberate + * decision here. + */ + +export interface EventEntityKeys { + /** Payload field holding the agent id (if any). */ + agent?: string; + /** Payload field holding the workflow id (if any). */ + workflow?: string; +} + +export const EVENT_ENTITY_KEYS: Record = { + // Agent-typed + agent_updated: { agent: 'agentId' }, + budget_exceeded: { agent: 'agentId' }, + chat_budget_exceeded_per_turn: { agent: 'agentId' }, + conversation_escalated: { agent: 'agentId' }, + + // Workflow-typed + workflow_failed: { workflow: 'workflowId' }, + approval_required: { workflow: 'workflowId' }, + workflow_budget_exceeded: { workflow: 'workflowId' }, + execution_crashed: { workflow: 'workflowId' }, + workflow_notification: { workflow: 'workflowId' }, + + // Unscopable — provider-level or system-wide; scoped subs always match + circuit_breaker_opened: {}, + + // Documented event types not currently fired with explicit entity IDs. + // Listed so the coverage test passes; scoped subs always match until a + // dispatch site adds the relevant ID to the payload and the row is + // updated. + conversation_started: {}, + conversation_completed: {}, + message_created: {}, + budget_threshold_reached: {}, + execution_completed: {}, + execution_failed: {}, +}; + +/** + * Apply dimension-specific entity scoping to a single subscription. + * + * Returns `true` when the subscription should receive the event, + * `false` when an entity filter excludes it. See the rules block at + * the top of this file for the precise semantics. + */ +export function matchesEntityScope( + eventType: string, + payload: Record, + sub: { agentIds?: string[] | null; workflowIds?: string[] | null } +): boolean { + const keys = EVENT_ENTITY_KEYS[eventType] ?? {}; + const agentIds = sub.agentIds ?? []; + const workflowIds = sub.workflowIds ?? []; + + if (agentIds.length > 0 && keys.agent) { + const payloadAgentId = payload[keys.agent]; + if (typeof payloadAgentId !== 'string' || !agentIds.includes(payloadAgentId)) { + return false; + } + } + + if (workflowIds.length > 0 && keys.workflow) { + const payloadWorkflowId = payload[keys.workflow]; + if (typeof payloadWorkflowId !== 'string' || !workflowIds.includes(payloadWorkflowId)) { + return false; + } + } + + return true; +} diff --git a/lib/validations/orchestration.ts b/lib/validations/orchestration.ts index 2331f3c80..8d2b1aba4 100644 --- a/lib/validations/orchestration.ts +++ b/lib/validations/orchestration.ts @@ -886,12 +886,22 @@ const eventsField = z .min(1, 'At least one event type is required') .max(WEBHOOK_EVENT_TYPES.length); +// Optional entity-scoping filters. Empty array = no constraint on that +// dimension. CUID check matches the rest of this file; 50-entry cap +// bounds payload size and keeps the in-memory dispatcher filter cheap. +// Semantics: dimension-specific — see lib/orchestration/webhooks/event-entity-keys.ts. +const entityScopeFields = { + agentIds: z.array(z.string().cuid()).max(50).optional(), + workflowIds: z.array(z.string().cuid()).max(50).optional(), +}; + const commonFields = { events: eventsField, description: z.string().max(500).optional(), isActive: z.boolean().optional(), maxAttempts: retryPolicyFields.maxAttempts.optional(), retryBackoffMs: retryPolicyFields.retryBackoffMs.optional(), + ...entityScopeFields, }; const webhookChannelFields = { @@ -948,6 +958,7 @@ const updateCommonFields = { isActive: z.boolean().optional(), maxAttempts: retryPolicyFields.maxAttempts.optional(), retryBackoffMs: retryPolicyFields.retryBackoffMs.optional(), + ...entityScopeFields, }; const updateWebhookChannelFields = { diff --git a/prisma/migrations/20260523120000_add_entity_scope_to_webhook_subscription/migration.sql b/prisma/migrations/20260523120000_add_entity_scope_to_webhook_subscription/migration.sql new file mode 100644 index 000000000..cabcb0a15 --- /dev/null +++ b/prisma/migrations/20260523120000_add_entity_scope_to_webhook_subscription/migration.sql @@ -0,0 +1,8 @@ +-- Add optional entity-scoping arrays so a single subscription can be limited to +-- specific agents and/or workflows. Empty array means "no constraint on this +-- dimension". The dispatcher applies dimension-specific filtering — see +-- lib/orchestration/webhooks/event-entity-keys.ts. + +ALTER TABLE "ai_webhook_subscription" + ADD COLUMN "agentIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + ADD COLUMN "workflowIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c3db9b1db..51f445f6d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1160,6 +1160,13 @@ model AiWebhookSubscription { secret String? // HMAC-SHA256 signing key — required when channel = "webhook" emailAddress String? // destination email — required when channel = "email" events String[] // e.g. ["budget_exceeded","workflow_failed"] + // Optional entity scoping. Empty array means "no constraint on this dimension". + // The dispatcher applies dimension-specific filtering: `agentIds` only constrains + // events whose payload carries an agent ID, `workflowIds` only constrains events + // whose payload carries a workflow ID. A sub with `agentIds=['x']` still receives + // workflow-typed events unconstrained. See lib/orchestration/webhooks/event-entity-keys.ts. + agentIds String[] @default([]) + workflowIds String[] @default([]) isActive Boolean @default(true) description String? // Total delivery attempts before the delivery is marked `exhausted` (1–10). diff --git a/tests/unit/components/admin/orchestration/webhook-form.test.tsx b/tests/unit/components/admin/orchestration/webhook-form.test.tsx index 252726365..7ba28cbbf 100644 --- a/tests/unit/components/admin/orchestration/webhook-form.test.tsx +++ b/tests/unit/components/admin/orchestration/webhook-form.test.tsx @@ -170,6 +170,8 @@ describe('WebhookForm', () => { description: null, channel: 'webhook' as const, emailAddress: null, + agentIds: [], + workflowIds: [], maxAttempts: 5, retryBackoffMs: [15_000, 60_000, 120_000, 600_000], }; @@ -315,6 +317,8 @@ describe('WebhookForm', () => { description: 'note', channel: 'webhook' as const, emailAddress: null, + agentIds: [], + workflowIds: [], maxAttempts: 3, retryBackoffMs: [10000, 60000], }; @@ -359,6 +363,8 @@ describe('WebhookForm', () => { description: 'note', channel: 'webhook' as const, emailAddress: null, + agentIds: [], + workflowIds: [], maxAttempts: 3, retryBackoffMs: [10000, 60000], }; @@ -398,6 +404,8 @@ describe('WebhookForm', () => { description: null, channel: 'webhook' as const, emailAddress: null, + agentIds: [], + workflowIds: [], maxAttempts: 3, retryBackoffMs: [10000, 60000], }; @@ -439,6 +447,8 @@ describe('WebhookForm', () => { description: null, channel: 'webhook' as const, emailAddress: null, + agentIds: [], + workflowIds: [], maxAttempts: 3, retryBackoffMs: [10000, 60000], }; @@ -536,6 +546,8 @@ describe('WebhookForm', () => { description: null, channel: 'webhook' as const, emailAddress: null, + agentIds: [], + workflowIds: [], maxAttempts: 3, retryBackoffMs: [10000, 60000], }; @@ -765,4 +777,144 @@ describe('WebhookForm', () => { expect(screen.getByText(/could not copy to clipboard/i)).toBeInTheDocument(); }); }); + + // ── Entity-scope MultiSelects ────────────────────────────────────────────── + // + // The Scope block adds two async-search MultiSelects (agents + workflows) + // plus a prefetch effect that resolves stored IDs back to chip labels. + // These tests cover the loaders, the URL filters they apply, and the + // prefetch behaviour for edit-mode rows. + + it('renders the Scope section with agent + workflow filter labels', () => { + render(); + + expect(screen.getByText('Scope')).toBeInTheDocument(); + expect(screen.getByText(/limit to agents/i)).toBeInTheDocument(); + expect(screen.getByText(/limit to workflows/i)).toBeInTheDocument(); + }); + + it('pre-fetches agent + workflow labels for stored IDs so chips render names not CUIDs', async () => { + // Edit mode with one stored agentId and one stored workflowId. The + // prefetch effect should call the admin list endpoints (limit=100, no + // `q`), find the matching rows, and pass the names through as chip + // labels in the MultiSelect. + const { apiClient } = await import('@/lib/api/client'); + vi.mocked(apiClient.get).mockImplementation(async (url: string) => { + if (url.includes('/agents')) { + return [ + { id: 'agent-X', name: 'Support Agent', slug: 'support' }, + { id: 'agent-Y', name: 'Other Agent', slug: 'other' }, + ]; + } + if (url.includes('/workflows')) { + return [{ id: 'wf-1', name: 'Refund Workflow', slug: 'refund' }]; + } + return []; + }); + + const webhook = { + id: 'wh-scoped', + url: 'https://x.com', + events: ['budget_exceeded'], + channel: 'webhook' as const, + emailAddress: null, + agentIds: ['agent-X'], + workflowIds: ['wf-1'], + isActive: true, + description: null, + maxAttempts: 3, + retryBackoffMs: [10000, 60000], + }; + + render(); + + // Chip labels resolve via the prefetch — assert both names appear. + await waitFor(() => { + expect(screen.getByText('Support Agent')).toBeInTheDocument(); + expect(screen.getByText('Refund Workflow')).toBeInTheDocument(); + }); + + // The prefetch must NOT call the loader with a `q` query — it's a + // bare list call so existing IDs can be matched. + const calls = vi.mocked(apiClient.get).mock.calls.map((c) => c[0]); + expect(calls.some((u) => u.includes('/agents?limit=100'))).toBe(true); + expect(calls.some((u) => u.includes('/workflows?limit=100'))).toBe(true); + // None of the prefetch calls carry a search query. + expect(calls.some((u) => u.includes('/agents') && u.includes('q='))).toBe(false); + }); + + it('does NOT prefetch labels when the form starts with empty scope arrays', async () => { + const { apiClient } = await import('@/lib/api/client'); + vi.mocked(apiClient.get).mockResolvedValue([]); + + render(); + + // Brief settle — the effect runs synchronously after mount. + await new Promise((r) => setTimeout(r, 0)); + + // Empty scope = no fetch. Saves a request on every create-form load. + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it('the workflow loader filters out templates via isTemplate=false', async () => { + // The Scope picker should only surface instantiated runtime workflows + // — templates aren't entities the engine fires events for, so scoping + // a sub to one is a no-op. The loader applies the filter server-side + // via `?isTemplate=false`. This test opens the workflow MultiSelect, + // waits past the 200ms debounce, and inspects the URL. + const user = userEvent.setup(); + const { apiClient } = await import('@/lib/api/client'); + vi.mocked(apiClient.get).mockResolvedValue([]); + + render(); + + // Two MultiSelect triggers exist (agents + workflows). The second one + // is the workflow picker — they appear in document order. + const triggers = screen.getAllByRole('combobox'); + expect(triggers.length).toBeGreaterThanOrEqual(2); + await user.click(triggers[1]); + + await waitFor( + () => { + const calls = vi.mocked(apiClient.get).mock.calls.map((c) => c[0]); + const workflowCall = calls.find((u) => u.includes('/workflows')); + expect(workflowCall, 'workflow loader URL').toBeDefined(); + expect(workflowCall).toContain('isTemplate=false'); + }, + { timeout: 1000 } + ); + }); + + it('omits the prefetch fetch when only one dimension is populated', async () => { + // Sanity-check the dimension-specific prefetch: a sub with agentIds + // but no workflowIds should fetch agents only. + const { apiClient } = await import('@/lib/api/client'); + vi.mocked(apiClient.get).mockImplementation(async () => [ + { id: 'agent-X', name: 'Support', slug: 'support' }, + ]); + + const webhook = { + id: 'wh-agent-only', + url: 'https://x.com', + events: ['budget_exceeded'], + channel: 'webhook' as const, + emailAddress: null, + agentIds: ['agent-X'], + workflowIds: [], + isActive: true, + description: null, + maxAttempts: 3, + retryBackoffMs: [10000, 60000], + }; + + render(); + + await waitFor(() => { + expect(screen.getByText('Support')).toBeInTheDocument(); + }); + + const calls = vi.mocked(apiClient.get).mock.calls.map((c) => c[0]); + expect(calls.some((u) => u.includes('/agents'))).toBe(true); + expect(calls.every((u) => !u.includes('/workflows'))).toBe(true); + }); }); diff --git a/tests/unit/components/admin/orchestration/webhooks-table.test.tsx b/tests/unit/components/admin/orchestration/webhooks-table.test.tsx index 9577c2f07..74b1e7fce 100644 --- a/tests/unit/components/admin/orchestration/webhooks-table.test.tsx +++ b/tests/unit/components/admin/orchestration/webhooks-table.test.tsx @@ -67,6 +67,8 @@ const MOCK_WEBHOOKS: WebhookListItem[] = [ id: 'wh-1', url: 'https://example.com/hooks/sunrise', events: ['budget_exceeded', 'workflow_failed'], + agentIds: [], + workflowIds: [], isActive: true, description: 'Slack alerts', createdAt: '2026-01-15T00:00:00Z', @@ -77,6 +79,8 @@ const MOCK_WEBHOOKS: WebhookListItem[] = [ id: 'wh-2', url: 'https://other.com/webhook', events: ['message_created'], + agentIds: [], + workflowIds: [], isActive: false, description: null, createdAt: '2026-02-01T00:00:00Z', @@ -217,6 +221,8 @@ describe('WebhooksTable', () => { 'message_created', 'execution_failed', ], + agentIds: [], + workflowIds: [], isActive: true, description: null, createdAt: '2026-01-01T00:00:00Z', @@ -251,6 +257,8 @@ describe('WebhooksTable', () => { id: 'wh-long', url: longUrl, events: ['budget_exceeded'], + agentIds: [], + workflowIds: [], isActive: true, description: null, createdAt: '2026-01-01T00:00:00Z', @@ -486,4 +494,76 @@ describe('WebhooksTable', () => { expect(screen.getByText(/could not update webhook: fail/i)).toBeInTheDocument(); }); }); + + // ── Entity-scope "Scoped" badge ───────────────────────────────────────────── + + it('renders the Scoped badge when a row has agentIds set', () => { + const scopedByAgent: WebhookListItem = { + id: 'wh-scoped-agent', + url: 'https://scoped.com/hook', + events: ['budget_exceeded'], + agentIds: ['agent-X'], + workflowIds: [], + isActive: true, + description: null, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + _count: { deliveries: 0 }, + }; + + render( + + ); + + // Badge label + expect(screen.getByText('Scoped')).toBeInTheDocument(); + // Tooltip carries the agent/workflow counts so admins can see the + // shape without opening the edit page. + const badge = screen.getByText('Scoped'); + expect(badge).toHaveAttribute('title', 'Scoped to 1 agent(s), 0 workflow(s)'); + }); + + it('renders the Scoped badge when a row has workflowIds set', () => { + const scopedByWorkflow: WebhookListItem = { + id: 'wh-scoped-wf', + url: 'https://scoped.com/hook', + events: ['workflow_failed'], + agentIds: [], + workflowIds: ['wf-1', 'wf-2'], + isActive: true, + description: null, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + _count: { deliveries: 0 }, + }; + + render( + + ); + + expect(screen.getByText('Scoped')).toHaveAttribute( + 'title', + 'Scoped to 0 agent(s), 2 workflow(s)' + ); + }); + + it('does NOT render the Scoped badge when both filter arrays are empty', () => { + // Backward-compat row: a sub created before entity scoping (or one that + // intentionally targets every agent / workflow). The badge would be + // misleading here — the sub is global by design. + render( + + ); + + expect(screen.queryByText('Scoped')).not.toBeInTheDocument(); + }); }); diff --git a/tests/unit/lib/orchestration/engine/events.test.ts b/tests/unit/lib/orchestration/engine/events.test.ts index ba0a54f27..632ed62d0 100644 --- a/tests/unit/lib/orchestration/engine/events.test.ts +++ b/tests/unit/lib/orchestration/engine/events.test.ts @@ -300,6 +300,20 @@ describe('Event factory helpers', () => { }); }); + it('includes workflowId in the webhook payload when supplied (for entity-scoped subs)', () => { + // workflow_budget_exceeded is workflow-typed; scoped subs filter on + // `workflowId` (see lib/orchestration/webhooks/event-entity-keys.ts), + // so the engine call sites must enrich the payload with it. + workflowBudgetExceeded(1.5, 1.0, 'step-llm', 'exec-123', 'wf-1'); + expect(dispatchWebhookEvent).toHaveBeenCalledWith('workflow_budget_exceeded', { + usedUsd: 1.5, + limitUsd: 1.0, + failedStepId: 'step-llm', + executionId: 'exec-123', + workflowId: 'wf-1', + }); + }); + it('does not throw when the webhook dispatch rejects — logs a warning instead', async () => { vi.mocked(dispatchWebhookEvent).mockRejectedValueOnce(new Error('Webhook offline')); diff --git a/tests/unit/lib/orchestration/webhooks/dispatcher.test.ts b/tests/unit/lib/orchestration/webhooks/dispatcher.test.ts index 16f4dc87e..b3933a72f 100644 --- a/tests/unit/lib/orchestration/webhooks/dispatcher.test.ts +++ b/tests/unit/lib/orchestration/webhooks/dispatcher.test.ts @@ -66,6 +66,8 @@ function makeSub(overrides: Record = {}) { secret: 'test-secret-key-1234567890', emailAddress: null, events: ['budget_exceeded', 'workflow_failed'], + agentIds: [] as string[], + workflowIds: [] as string[], isActive: true, description: null, maxAttempts: 3, @@ -965,3 +967,116 @@ describe('webhook-channel delivery edge cases', () => { ).toBe(true); }); }); + +// ─── Entity-scoped matching ────────────────────────────────────────────────── +// +// Verifies the dimension-specific filter rule introduced alongside the +// `agentIds` / `workflowIds` columns. See +// lib/orchestration/webhooks/event-entity-keys.ts for the contract. +describe('dispatchWebhookEvent: entity-scoped matching', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue({ ok: true, status: 200 }); + vi.mocked(prisma.aiWebhookDelivery.create).mockResolvedValue(makeDelivery() as never); + vi.mocked(prisma.aiWebhookDelivery.update).mockResolvedValue(makeDelivery() as never); + vi.mocked(prisma.aiWebhookDelivery.findUnique).mockResolvedValue(makeDelivery() as never); + }); + + it('delivers to a sub with empty filters (backward compat)', async () => { + vi.mocked(prisma.aiWebhookSubscription.findMany).mockResolvedValue([makeSub()] as never); + + await dispatchWebhookEvent('budget_exceeded', { agentId: 'agent-X' }); + + expect(prisma.aiWebhookDelivery.create).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('delivers when payload agentId matches the agentIds filter', async () => { + vi.mocked(prisma.aiWebhookSubscription.findMany).mockResolvedValue([ + makeSub({ agentIds: ['agent-X', 'agent-Y'] }), + ] as never); + + await dispatchWebhookEvent('budget_exceeded', { agentId: 'agent-X' }); + + expect(prisma.aiWebhookDelivery.create).toHaveBeenCalledTimes(1); + }); + + it('skips a sub when payload agentId is NOT in the agentIds filter', async () => { + vi.mocked(prisma.aiWebhookSubscription.findMany).mockResolvedValue([ + makeSub({ agentIds: ['agent-X'] }), + ] as never); + + await dispatchWebhookEvent('budget_exceeded', { agentId: 'agent-Z' }); + + expect(prisma.aiWebhookDelivery.create).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('delivers a workflow-typed event regardless of an agent-only filter (dimension-specific)', async () => { + // The sub cares about agents, but workflow_failed is a workflow event. + // Agent filter doesn't apply → event still fires. + vi.mocked(prisma.aiWebhookSubscription.findMany).mockResolvedValue([ + makeSub({ agentIds: ['agent-X'], events: ['workflow_failed'] }), + ] as never); + + await dispatchWebhookEvent('workflow_failed', { workflowId: 'wf-1', error: 'boom' }); + + expect(prisma.aiWebhookDelivery.create).toHaveBeenCalledTimes(1); + }); + + it('delivers an agent-typed event regardless of a workflow-only filter', async () => { + vi.mocked(prisma.aiWebhookSubscription.findMany).mockResolvedValue([ + makeSub({ workflowIds: ['wf-1'], events: ['budget_exceeded'] }), + ] as never); + + await dispatchWebhookEvent('budget_exceeded', { agentId: 'agent-X' }); + + expect(prisma.aiWebhookDelivery.create).toHaveBeenCalledTimes(1); + }); + + it('delivers when both dimensions match', async () => { + vi.mocked(prisma.aiWebhookSubscription.findMany).mockResolvedValue([ + makeSub({ + agentIds: ['agent-X'], + workflowIds: ['wf-1'], + events: ['budget_exceeded', 'workflow_failed'], + }), + ] as never); + + await dispatchWebhookEvent('workflow_failed', { workflowId: 'wf-1', error: 'boom' }); + await dispatchWebhookEvent('budget_exceeded', { agentId: 'agent-X' }); + + expect(prisma.aiWebhookDelivery.create).toHaveBeenCalledTimes(2); + }); + + it('fails closed: scoped sub does NOT match when payload is missing the expected ID', async () => { + // Defensive: if an upstream dispatch site forgets to include agentId in + // a `budget_exceeded` payload, a scoped sub should NOT leak — better + // to drop the event than send it to the wrong subscriber. + vi.mocked(prisma.aiWebhookSubscription.findMany).mockResolvedValue([ + makeSub({ agentIds: ['agent-X'] }), + ] as never); + + await dispatchWebhookEvent('budget_exceeded', { + /* missing agentId */ + }); + + expect(prisma.aiWebhookDelivery.create).not.toHaveBeenCalled(); + }); + + it('routes only to the matching sub when one sub is scoped and another is global', async () => { + vi.mocked(prisma.aiWebhookSubscription.findMany).mockResolvedValue([ + makeSub({ id: 'sub-scoped', agentIds: ['agent-X'] }), + makeSub({ id: 'sub-global' }), + ] as never); + + await dispatchWebhookEvent('budget_exceeded', { agentId: 'agent-Y' }); + + // Only the global sub gets a delivery; the scoped one is filtered out. + expect(prisma.aiWebhookDelivery.create).toHaveBeenCalledTimes(1); + const createdSubIds = vi + .mocked(prisma.aiWebhookDelivery.create) + .mock.calls.map((c) => (c[0]?.data as Record)?.subscriptionId); + expect(createdSubIds).toEqual(['sub-global']); + }); +}); diff --git a/tests/unit/lib/orchestration/webhooks/event-entity-keys.test.ts b/tests/unit/lib/orchestration/webhooks/event-entity-keys.test.ts new file mode 100644 index 000000000..ca05193d4 --- /dev/null +++ b/tests/unit/lib/orchestration/webhooks/event-entity-keys.test.ts @@ -0,0 +1,111 @@ +/** + * Event entity-keys map + matcher. + * + * Covers the pure helper in isolation. The dispatcher integration is + * covered separately in dispatcher.test.ts under + * "dispatchWebhookEvent: entity-scoped matching". + * + * @see lib/orchestration/webhooks/event-entity-keys.ts + */ + +import { describe, it, expect } from 'vitest'; +import { + EVENT_ENTITY_KEYS, + matchesEntityScope, +} from '@/lib/orchestration/webhooks/event-entity-keys'; +import { WEBHOOK_EVENT_TYPES } from '@/lib/validations/orchestration'; + +describe('EVENT_ENTITY_KEYS map coverage', () => { + // Forces a deliberate decision when a new event type is added: pick the + // payload field that carries the agent/workflow id, or leave `{}` to + // declare "no scopable entity". Failing this test means the map is out + // of sync with the catalogue of wired events. + it('contains an entry for every webhook event type in WEBHOOK_EVENT_TYPES', () => { + for (const event of WEBHOOK_EVENT_TYPES) { + expect(EVENT_ENTITY_KEYS, `missing entry for event "${event}"`).toHaveProperty(event); + } + }); +}); + +describe('matchesEntityScope', () => { + // Empty filters → no constraint. Backward compatible with pre-scoping rows. + it('returns true when both filters are empty', () => { + expect( + matchesEntityScope('budget_exceeded', { agentId: 'a1' }, { agentIds: [], workflowIds: [] }) + ).toBe(true); + }); + + it('returns true when the payload agentId is in the agentIds filter', () => { + expect( + matchesEntityScope('budget_exceeded', { agentId: 'a1' }, { agentIds: ['a1', 'a2'] }) + ).toBe(true); + }); + + it('returns false when the payload agentId is NOT in the agentIds filter', () => { + expect(matchesEntityScope('budget_exceeded', { agentId: 'a3' }, { agentIds: ['a1'] })).toBe( + false + ); + }); + + it('ignores the agent filter for workflow-typed events (dimension-specific)', () => { + // The sub cares about agents, but workflow_failed has no agent + // dimension — the filter must not block it. + expect( + matchesEntityScope( + 'workflow_failed', + { workflowId: 'wf-1', error: 'boom' }, + { agentIds: ['a1'], workflowIds: [] } + ) + ).toBe(true); + }); + + it('ignores the workflow filter for agent-typed events', () => { + expect( + matchesEntityScope( + 'budget_exceeded', + { agentId: 'a1' }, + { agentIds: [], workflowIds: ['wf-1'] } + ) + ).toBe(true); + }); + + it('requires both dimensions to match when both are filtered', () => { + expect( + matchesEntityScope( + 'budget_exceeded', + { agentId: 'a1' }, + { agentIds: ['a1'], workflowIds: ['wf-1'] } + ) + ).toBe(true); + }); + + it('fails closed when an agent filter is set but the payload is missing agentId', () => { + // Defensive contract: never leak a scoped sub when the dispatch site + // forgot to enrich the payload. Better to drop the event than route + // it to the wrong subscriber. + expect(matchesEntityScope('budget_exceeded', {}, { agentIds: ['a1'] })).toBe(false); + }); + + it('returns true for unscopable events (e.g. circuit_breaker_opened) even with filters set', () => { + // No agent/workflow keys mapped — filters can't apply. Scoped sub + // still gets the event so admins don't accidentally mute breaker + // notifications by setting an unrelated filter. + expect( + matchesEntityScope( + 'circuit_breaker_opened', + { providerSlug: 'openai' }, + { agentIds: ['a1'], workflowIds: ['wf-1'] } + ) + ).toBe(true); + }); + + it('tolerates a non-string payload entity value (fails closed when filtered)', () => { + expect( + matchesEntityScope( + 'budget_exceeded', + { agentId: 123 as unknown as string }, + { agentIds: ['a1'] } + ) + ).toBe(false); + }); +});