Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .context/admin/orchestration-webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions .context/orchestration/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/admin/orchestration/event-subscriptions/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions app/api/v1/admin/orchestration/webhooks/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const SAFE_SELECT = {
url: true,
emailAddress: true,
events: true,
agentIds: true,
workflowIds: true,
isActive: true,
description: true,
maxAttempts: true,
Expand Down
6 changes: 6 additions & 0 deletions app/api/v1/admin/orchestration/webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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') {
Expand All @@ -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,
Expand Down
162 changes: 161 additions & 1 deletion components/admin/orchestration/webhook-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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<Record<string, string>>({});
const [selectedWorkflowLabels, setSelectedWorkflowLabels] = useState<Record<string, string>>({});

useEffect(() => {
if (currentAgentIds.length === 0) return;
let cancelled = false;
void (async () => {
try {
const agents = await apiClient.get<Array<{ id: string; name: string; slug: string }>>(
`${API.ADMIN.ORCHESTRATION.AGENTS}?limit=100`
);
if (cancelled) return;
const labels: Record<string, string> = {};
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<Array<{ id: string; name: string; slug: string }>>(
`${API.ADMIN.ORCHESTRATION.WORKFLOWS}?limit=100`
);
if (cancelled) return;
const labels: Record<string, string> = {};
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<MultiSelectOption[]> {
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<Array<{ id: string; name: string; slug: string }>>(
`${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<MultiSelectOption[]> {
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<Array<{ id: string; name: string; slug: string }>>(
`${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);
Expand Down Expand Up @@ -272,6 +373,8 @@ export function WebhookForm({ mode, webhook }: WebhookFormProps) {
const payload: Record<string, unknown> = {
channel: data.channel,
events: data.events,
agentIds: data.agentIds,
workflowIds: data.workflowIds,
description: data.description,
isActive: data.isActive,
maxAttempts: data.maxAttempts,
Expand Down Expand Up @@ -584,6 +687,63 @@ export function WebhookForm({ mode, webhook }: WebhookFormProps) {
{errors.events && <p className="text-destructive text-xs">{errors.events.message}</p>}
</div>

{/* Entity scope */}
<div className="grid gap-4 rounded-lg border p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Scope</p>
<p className="text-muted-foreground text-xs">
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.
</p>
</div>

<div className="grid gap-2">
<Label htmlFor="webhook-agent-scope">
Limit to agents{' '}
<FieldHelp title="Limit to specific agents">
Only fire when the event is about one of the agents you select here. Leave empty to
receive events for all agents. Events that aren&apos;t about an agent (like
workflow_failed) ignore this filter — set the workflow filter below for those.
</FieldHelp>
</Label>
<MultiSelect
id="webhook-agent-scope"
value={currentAgentIds}
onChange={(next) => setValue('agentIds', next, { shouldValidate: true })}
loadOptions={loadAgentOptions}
selectedLabels={selectedAgentLabels}
placeholder="All agents"
emptyText="No matching agents."
/>
{errors.agentIds && <p className="text-destructive text-xs">{errors.agentIds.message}</p>}
</div>

<div className="grid gap-2">
<Label htmlFor="webhook-workflow-scope">
Limit to workflows{' '}
<FieldHelp title="Limit to specific workflows">
Only fire when the event is about one of the workflows you select here. Leave empty to
receive events for all workflows. Events that aren&apos;t about a workflow (like
budget_exceeded for a chat agent) ignore this filter — set the agent filter above for
those.
</FieldHelp>
</Label>
<MultiSelect
id="webhook-workflow-scope"
value={currentWorkflowIds}
onChange={(next) => setValue('workflowIds', next, { shouldValidate: true })}
loadOptions={loadWorkflowOptions}
selectedLabels={selectedWorkflowLabels}
placeholder="All workflows"
emptyText="No matching workflows."
/>
{errors.workflowIds && (
<p className="text-destructive text-xs">{errors.workflowIds.message}</p>
)}
</div>
</div>

{/* Retry policy */}
<div className="grid gap-4 rounded-lg border p-4">
<div className="space-y-0.5">
Expand Down
11 changes: 11 additions & 0 deletions components/admin/orchestration/webhooks-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export interface WebhookListItem {
id: string;
url: string;
events: string[];
agentIds: string[];
workflowIds: string[];
isActive: boolean;
description: string | null;
createdAt: string;
Expand Down Expand Up @@ -226,6 +228,15 @@ export function WebhooksTable({ initialWebhooks, initialMeta }: WebhooksTablePro
+{wh.events.length - 3}
</Badge>
)}
{(wh.agentIds?.length ?? 0) + (wh.workflowIds?.length ?? 0) > 0 && (
<Badge
variant="outline"
className="text-[10px]"
title={`Scoped to ${wh.agentIds?.length ?? 0} agent(s), ${wh.workflowIds?.length ?? 0} workflow(s)`}
>
Scoped
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-center text-sm tabular-nums">
Expand Down
4 changes: 3 additions & 1 deletion lib/orchestration/engine/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading