From f1c6c6f59dee4a54e80013efcf1175e3b159b168 Mon Sep 17 00:00:00 2001 From: zbigniew sobiecki Date: Sun, 1 Mar 2026 19:07:56 +0100 Subject: [PATCH 1/2] fix(triggers): address code review findings for integration-driven architecture Schema validation improvements: - Add TriggerEventSchema with regex validation for {category}:{event-name} format - Add KnownProviderSchema enum (trello, jira, github, imap, gmail, twilio) - Add defaultValue type consistency refinement (must match parameter type) - Add refinement preventing required: true with defaultValue - Add IntegrationRequirementsSchema overlap refinement Repository fixes: - Fix bulkUpsertTriggerConfigs bug (was using first config's values for all conflicts) - Add getTriggerConfigById function for ownership verification - Use individual upserts in transaction for correct per-config values Router refactoring: - Replace direct DB access with repository functions in update/delete procedures - Clean up imports (remove unused getDb, eq) YAML definition fixes: - review.yaml: consolidate duplicate scm:check-suite-success triggers into single trigger with authorMode select parameter (own/external/all) - debug.yaml: remove undeclared pm:attachment-added trigger (agent is manually triggered) - implementation.yaml, planning.yaml, splitting.yaml: remove contradictory required: true from parameters that have defaultValue Tests: - Add comprehensive tests for TriggerParameterSchema validation - Add tests for SupportedTriggerSchema event format validation - Add tests for KnownProviderSchema - Add tests for IntegrationRequirementsSchema overlap - Add tests for AgentDefinitionSchema with triggers - Add full test suite for agentTriggerConfigs router (51 new tests) Co-Authored-By: Claude Opus 4.5 --- src/agents/definitions/debug.yaml | 9 + src/agents/definitions/email-joke.yaml | 19 + src/agents/definitions/implementation.yaml | 40 ++ src/agents/definitions/planning.yaml | 44 ++ src/agents/definitions/respond-to-ci.yaml | 13 + .../respond-to-planning-comment.yaml | 13 + .../definitions/respond-to-pr-comment.yaml | 13 + src/agents/definitions/respond-to-review.yaml | 13 + src/agents/definitions/review.yaml | 30 ++ src/agents/definitions/schema.ts | 137 +++++++ src/agents/definitions/splitting.yaml | 40 ++ src/api/router.ts | 2 + src/api/routers/agentTriggerConfigs.ts | 155 +++++++ .../migrations/0024_agent_trigger_configs.sql | 20 + src/db/migrations/meta/_journal.json | 7 + .../agentTriggerConfigsRepository.ts | 230 +++++++++++ src/db/schema/agentTriggerConfigs.ts | 35 ++ src/db/schema/index.ts | 1 + src/triggers/config-resolver.ts | 256 ++++++++++++ src/triggers/shared/integration-validation.ts | 17 +- tests/unit/agents/definitions/schema.test.ts | 385 +++++++++++++++++- .../unit/api/routers/agentDefinitions.test.ts | 1 + .../api/routers/agentTriggerConfigs.test.ts | 316 ++++++++++++++ web/src/lib/trigger-agent-mapping.ts | 45 ++ 24 files changed, 1838 insertions(+), 3 deletions(-) create mode 100644 src/api/routers/agentTriggerConfigs.ts create mode 100644 src/db/migrations/0024_agent_trigger_configs.sql create mode 100644 src/db/repositories/agentTriggerConfigsRepository.ts create mode 100644 src/db/schema/agentTriggerConfigs.ts create mode 100644 src/triggers/config-resolver.ts create mode 100644 tests/unit/api/routers/agentTriggerConfigs.test.ts diff --git a/src/agents/definitions/debug.yaml b/src/agents/definitions/debug.yaml index bd3fc516..da7caa48 100644 --- a/src/agents/definitions/debug.yaml +++ b/src/agents/definitions/debug.yaml @@ -4,6 +4,11 @@ identity: roleHint: Analyzes session logs to identify what went wrong initialMessage: "**\U0001F41B Analyzing session logs** — Reviewing what happened and identifying issues..." +# Explicit integration requirements. +integrations: + required: [pm] + optional: [] + # Read-only FS access for log analysis, full PM access for creating debug cards. capabilities: required: @@ -15,6 +20,10 @@ capabilities: - pm:checklist optional: [] +# Debug agent is triggered manually or via internal attachment upload detection. +# No external event-based triggers are configured. +triggers: [] + strategies: contextPipeline: [directoryListing, contextFiles, squint, workItem] diff --git a/src/agents/definitions/email-joke.yaml b/src/agents/definitions/email-joke.yaml index e18957fd..8b834b5f 100644 --- a/src/agents/definitions/email-joke.yaml +++ b/src/agents/definitions/email-joke.yaml @@ -4,6 +4,12 @@ identity: roleHint: Reads emails from a specific sender and responds with jokes initialMessage: "**\U0001F602 Checking for emails to respond to with jokes**" +# Explicit integration requirements. +# Email-joke agent demonstrates an agent that ONLY needs email integration. +integrations: + required: [email] + optional: [] + # Minimal agent with email access only. # No file editing, no GitHub, no PM tools. capabilities: @@ -13,6 +19,19 @@ capabilities: - email:write optional: [] +# Supported triggers for this agent +triggers: + - event: email:received + label: Email Received + description: Trigger when new emails are received (polled periodically) + defaultEnabled: true + parameters: + - name: senderEmail + type: email + label: Sender Email Filter + description: Only process emails from this sender (leave empty for all) + required: false + strategies: contextPipeline: [prefetchedEmails] diff --git a/src/agents/definitions/implementation.yaml b/src/agents/definitions/implementation.yaml index eb20be6f..7bb84483 100644 --- a/src/agents/definitions/implementation.yaml +++ b/src/agents/definitions/implementation.yaml @@ -4,6 +4,11 @@ identity: roleHint: Writes code, runs tests, and prepares a pull request initialMessage: "**\U0001F680 Implementing changes** — Writing code, running tests, and preparing a PR..." +# Explicit integration requirements. +integrations: + required: [pm, scm] + optional: [] + # Capabilities define what the agent can do. # Integrations and tools are DERIVED from these capabilities. capabilities: @@ -18,6 +23,41 @@ capabilities: - scm:pr optional: [] +# Supported triggers for this agent +triggers: + - event: pm:card-moved + label: Card Moved to Todo + description: Trigger when card moved to Todo list + defaultEnabled: true + providers: [trello] + parameters: + - name: targetList + type: select + label: Target List + options: [todo] + defaultValue: todo + - event: pm:issue-transitioned + label: Issue Transitioned + description: Trigger when issue transitions to Todo status + defaultEnabled: true + providers: [jira] + parameters: + - name: targetStatus + type: select + label: Target Status + options: [todo] + defaultValue: todo + - event: pm:label-added + label: Ready to Process Label + description: Trigger when Ready to Process label added to a card in the Todo list + defaultEnabled: true + parameters: + - name: listKey + type: select + label: Target List + options: [todo] + defaultValue: todo + strategies: contextPipeline: [directoryListing, contextFiles, squint, workItem] diff --git a/src/agents/definitions/planning.yaml b/src/agents/definitions/planning.yaml index 9cb17a4a..815d3708 100644 --- a/src/agents/definitions/planning.yaml +++ b/src/agents/definitions/planning.yaml @@ -4,6 +4,11 @@ identity: roleHint: Studies the codebase and designs a step-by-step implementation plan initialMessage: "**\U0001F5FA\uFE0F Planning implementation** — Studying the codebase and designing a step-by-step plan..." +# Explicit integration requirements. +integrations: + required: [pm] + optional: [] + # Read-only agent that explores code and writes plans to PM system. # No file editing, no PR creation, no checklist updates. capabilities: @@ -15,6 +20,45 @@ capabilities: - pm:write optional: [] +# Supported triggers for this agent +triggers: + - event: pm:card-moved + label: Card Moved to Planning + description: Trigger when card moved to Planning list + defaultEnabled: true + providers: [trello] + parameters: + - name: targetList + type: select + label: Target List + options: [planning] + defaultValue: planning + - event: pm:issue-transitioned + label: Issue Transitioned + description: Trigger when issue transitions to Planning status + defaultEnabled: true + providers: [jira] + parameters: + - name: targetStatus + type: select + label: Target Status + options: [planning] + defaultValue: planning + - event: pm:label-added + label: Ready to Process Label + description: Trigger when Ready to Process label added to a card in Planning list + defaultEnabled: true + parameters: + - name: listKey + type: select + label: Target List + options: [planning] + defaultValue: planning + - event: pm:comment-mention + label: Comment @mention + description: Trigger when bot is @mentioned in a card/issue comment + defaultEnabled: true + strategies: contextPipeline: [directoryListing, contextFiles, squint, workItem] diff --git a/src/agents/definitions/respond-to-ci.yaml b/src/agents/definitions/respond-to-ci.yaml index 7ea1ef14..194dfe48 100644 --- a/src/agents/definitions/respond-to-ci.yaml +++ b/src/agents/definitions/respond-to-ci.yaml @@ -4,6 +4,11 @@ identity: roleHint: Analyzes failed CI checks and works on a fix initialMessage: "**\U0001F527 Fixing CI failures** — Analyzing the failed checks and working on a fix..." +# Explicit integration requirements. +integrations: + required: [scm] + optional: [pm] + # Can edit files and read SCM, optional PM for status updates. capabilities: required: @@ -18,6 +23,14 @@ capabilities: - pm:write - pm:checklist +# Supported triggers for this agent +triggers: + - event: scm:check-suite-failure + label: Check Suite Failure + description: Trigger when CI checks fail + defaultEnabled: true + providers: [github] + strategies: contextPipeline: [prContext, directoryListing, contextFiles, squint, workItem] diff --git a/src/agents/definitions/respond-to-planning-comment.yaml b/src/agents/definitions/respond-to-planning-comment.yaml index ce67a612..10d7b17c 100644 --- a/src/agents/definitions/respond-to-planning-comment.yaml +++ b/src/agents/definitions/respond-to-planning-comment.yaml @@ -4,6 +4,11 @@ identity: roleHint: Reads user feedback and updates the plan accordingly initialMessage: "**\U0001F4AC Responding to feedback** — Reading your comment and updating the plan accordingly..." +# Explicit integration requirements. +integrations: + required: [pm] + optional: [] + # Can update PM checklists to respond to feedback, but no file editing. capabilities: required: @@ -15,6 +20,14 @@ capabilities: - pm:checklist optional: [] +# Supported triggers for this agent +# Note: This agent is triggered via pm:comment-mention shared with planning agent +triggers: + - event: pm:comment-mention + label: Comment @mention + description: Trigger when bot is @mentioned in a card/issue comment + defaultEnabled: true + strategies: contextPipeline: [directoryListing, contextFiles, squint, workItem] diff --git a/src/agents/definitions/respond-to-pr-comment.yaml b/src/agents/definitions/respond-to-pr-comment.yaml index f802be3b..6867588e 100644 --- a/src/agents/definitions/respond-to-pr-comment.yaml +++ b/src/agents/definitions/respond-to-pr-comment.yaml @@ -4,6 +4,11 @@ identity: roleHint: Reads a PR comment and takes action initialMessage: "**\U0001F4AC Responding to PR comment** — Reading your comment and taking action..." +# Explicit integration requirements. +integrations: + required: [scm] + optional: [pm] + # Can edit files and interact with PR comments, optional PM for status updates. capabilities: required: @@ -17,6 +22,14 @@ capabilities: - pm:read - pm:write +# Supported triggers for this agent +triggers: + - event: scm:pr-comment-mention + label: PR Comment @mention + description: Trigger when the implementer bot is @mentioned in a PR comment + defaultEnabled: true + providers: [github] + strategies: contextPipeline: [prContext, prConversation, directoryListing, contextFiles, squint] gadgetOptions: diff --git a/src/agents/definitions/respond-to-review.yaml b/src/agents/definitions/respond-to-review.yaml index 53770376..92e84997 100644 --- a/src/agents/definitions/respond-to-review.yaml +++ b/src/agents/definitions/respond-to-review.yaml @@ -4,6 +4,11 @@ identity: roleHint: Addresses code review feedback by making requested changes initialMessage: "**\U0001F527 Addressing review feedback** — Making the requested changes from the code review..." +# Explicit integration requirements. +integrations: + required: [scm] + optional: [pm] + # Can edit files to address review feedback, interact with PR comments. # Optional PM for status updates if configured. capabilities: @@ -18,6 +23,14 @@ capabilities: - pm:read - pm:write +# Supported triggers for this agent +triggers: + - event: scm:pr-review-submitted + label: PR Review Submitted + description: Trigger when a review with changes requested is submitted + defaultEnabled: true + providers: [github] + strategies: contextPipeline: [prContext, prConversation, directoryListing, contextFiles, squint] gadgetOptions: diff --git a/src/agents/definitions/review.yaml b/src/agents/definitions/review.yaml index 92e18f64..50cbdea1 100644 --- a/src/agents/definitions/review.yaml +++ b/src/agents/definitions/review.yaml @@ -4,6 +4,11 @@ identity: roleHint: Reviews pull request changes for quality and correctness initialMessage: "**\U0001F50D Reviewing code** — Examining the PR changes for quality and correctness..." +# Explicit integration requirements. +integrations: + required: [scm] + optional: [pm] + # Read-only agent that reviews PRs. Can submit reviews and update comments. # Optional PM for reading linked work item context. capabilities: @@ -17,6 +22,31 @@ capabilities: optional: - pm:read +# Supported triggers for this agent +triggers: + - event: scm:check-suite-success + label: CI Passed + description: Trigger review when CI checks pass on a PR + defaultEnabled: false + providers: [github] + parameters: + - name: authorMode + type: select + label: Author Filter + description: Filter PRs by author type + options: [own, external, all] + defaultValue: own + - event: scm:review-requested + label: On Review Requested + description: Trigger review when a CASCADE persona is explicitly requested as reviewer + defaultEnabled: false + providers: [github] + - event: scm:pr-opened + label: PR Opened + description: Trigger review when a new PR is opened (without waiting for CI) + defaultEnabled: false + providers: [github] + strategies: contextPipeline: [prContext, contextFiles, squint] diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts index 5d94d23e..8c7ce5b0 100644 --- a/src/agents/definitions/schema.ts +++ b/src/agents/definitions/schema.ts @@ -8,6 +8,117 @@ import { CAPABILITIES } from '../capabilities/registry.js'; // Integration categories (aligned with integrationRoles.ts) export const IntegrationCategorySchema = z.enum(['pm', 'scm', 'email', 'sms']); +// Known providers for validation +export const KnownProviderSchema = z.enum(['trello', 'jira', 'github', 'imap', 'gmail', 'twilio']); + +// Trigger event format validation: {category}:{event-name} +const TriggerEventSchema = z + .string() + .regex( + /^(pm|scm|email|sms):[a-z][a-z0-9-]*$/, + 'Event must be in format {category}:{event-name} (e.g., pm:card-moved, scm:check-suite-success)', + ); + +// ============================================================================ +// Trigger Parameter Schema +// ============================================================================ + +/** + * Parameter definition for agent triggers. + * Supports string, email, boolean, and select types. + */ +export const TriggerParameterSchema = z + .object({ + /** Parameter name (used as key in configuration) */ + name: z.string(), + /** Parameter type - determines input widget */ + type: z.enum(['string', 'email', 'boolean', 'select']), + /** Human-readable label for the parameter */ + label: z.string(), + /** Optional description for help text */ + description: z.string().optional(), + /** Whether the parameter is required (cannot be true if defaultValue is set) */ + required: z.boolean().default(false), + /** Default value for the parameter (type must match parameter type) */ + defaultValue: z.union([z.string(), z.boolean(), z.number()]).optional(), + /** Options for 'select' type parameters */ + options: z.array(z.string()).optional(), + }) + .refine( + (p) => { + // Validate defaultValue type matches parameter type + if (p.defaultValue === undefined) return true; + if (p.type === 'boolean') return typeof p.defaultValue === 'boolean'; + if (p.type === 'string' || p.type === 'email' || p.type === 'select') { + return typeof p.defaultValue === 'string'; + } + return true; + }, + { message: 'defaultValue type must match parameter type' }, + ) + .refine( + (p) => { + // If defaultValue is set, required should be false + if (p.defaultValue !== undefined && p.required === true) { + return false; + } + return true; + }, + { message: 'Parameter with defaultValue cannot be required' }, + ); + +// ============================================================================ +// Supported Trigger Schema +// ============================================================================ + +/** + * Trigger that an agent can be activated by. + * Uses category-prefixed naming: {category}:{event} + * + * Examples: + * - pm:card-moved (card moved to a list) + * - pm:issue-transitioned (JIRA issue status change) + * - scm:check-suite-success (CI passed) + * - email:received (new email received) + */ +export const SupportedTriggerSchema = z.object({ + /** Event identifier, e.g., 'pm:card-moved', 'scm:check-suite-success' */ + event: TriggerEventSchema, + /** Human-readable label for the trigger */ + label: z.string(), + /** Optional description for help text */ + description: z.string().optional(), + /** Whether the trigger is enabled by default */ + defaultEnabled: z.boolean().default(true), + /** Configurable parameters for this trigger */ + parameters: z.array(TriggerParameterSchema).default([]), + /** Provider filter - only applies to these providers (e.g., ['trello']) */ + providers: z.array(KnownProviderSchema).optional(), +}); + +// ============================================================================ +// Integration Requirements Schema +// ============================================================================ + +/** + * Explicit integration requirements for an agent. + * Replaces the implicit derivation from capabilities. + */ +export const IntegrationRequirementsSchema = z + .object({ + /** Integration categories the agent REQUIRES */ + required: z.array(IntegrationCategorySchema).default([]), + /** Integration categories the agent CAN USE if available */ + optional: z.array(IntegrationCategorySchema).default([]), + }) + .refine( + (data) => { + const overlap = data.required.filter((c) => data.optional.includes(c)); + return overlap.length === 0; + }, + { message: 'Integration cannot be both required and optional' }, + ); + const IdentitySchema = z.object({ emoji: z.string(), label: z.string(), @@ -114,15 +225,29 @@ const PromptsSchema = z.object({ * - Which integrations are required (derived from capability prefixes) * - Which gadgets are available (from capability registry) * - Which SDK tools are enabled (from capability registry) + * + * NEW: Explicit integrations and triggers can be defined independently of capabilities. + * - integrations: Explicit required/optional integration categories + * - triggers: Supported trigger events with configurable parameters */ export const AgentDefinitionSchema = z.object({ /** Agent identity for UI display */ identity: IdentitySchema, + /** + * Explicit integration requirements. + * If not specified, integrations are derived from capabilities. + */ + integrations: IntegrationRequirementsSchema.optional(), /** * Capabilities define what the agent can do. * Integrations and tools are DERIVED from capabilities. */ capabilities: CapabilitiesSchema, + /** + * Supported triggers that can activate this agent. + * Declares what events the agent can respond to, with configurable parameters. + */ + triggers: z.array(SupportedTriggerSchema).default([]), /** Strategy configuration (context pipeline, prompts) */ strategies: StrategiesSchema, /** Backend execution configuration */ @@ -152,3 +277,15 @@ export type { Capability } from '../capabilities/registry.js'; /** Agent capabilities (required + optional) */ export type AgentCapabilities = z.infer; + +/** Trigger parameter definition */ +export type TriggerParameter = z.infer; + +/** Supported trigger definition */ +export type SupportedTrigger = z.infer; + +/** Integration requirements (explicit required/optional) */ +export type IntegrationRequirements = z.infer; + +/** Known provider (trello, jira, github, etc.) */ +export type KnownProvider = z.infer; diff --git a/src/agents/definitions/splitting.yaml b/src/agents/definitions/splitting.yaml index 9ed61fe0..a976b1ab 100644 --- a/src/agents/definitions/splitting.yaml +++ b/src/agents/definitions/splitting.yaml @@ -4,6 +4,11 @@ identity: roleHint: Breaks down a feature plan into smaller, ordered work items (subtasks) initialMessage: "**\U0001F4CB Splitting plan** — Reading the plan and splitting it into ordered work items..." +# Explicit integration requirements. +integrations: + required: [pm] + optional: [] + # Can read files and edit PM checklists, but no PR creation. capabilities: required: @@ -16,6 +21,41 @@ capabilities: - pm:checklist optional: [] +# Supported triggers for this agent +triggers: + - event: pm:card-moved + label: Card Moved to Splitting + description: Trigger when card moved to Splitting list + defaultEnabled: true + providers: [trello] + parameters: + - name: targetList + type: select + label: Target List + options: [splitting] + defaultValue: splitting + - event: pm:issue-transitioned + label: Issue Transitioned + description: Trigger when issue transitions to Splitting status + defaultEnabled: true + providers: [jira] + parameters: + - name: targetStatus + type: select + label: Target Status + options: [splitting] + defaultValue: splitting + - event: pm:label-added + label: Ready to Process Label + description: Trigger when Ready to Process label added to a card in Splitting list + defaultEnabled: true + parameters: + - name: listKey + type: select + label: Target List + options: [splitting] + defaultValue: splitting + strategies: contextPipeline: [directoryListing, contextFiles, squint, workItem] diff --git a/src/api/router.ts b/src/api/router.ts index 0fc8f8c9..9659e00f 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -1,5 +1,6 @@ import { agentConfigsRouter } from './routers/agentConfigs.js'; import { agentDefinitionsRouter } from './routers/agentDefinitions.js'; +import { agentTriggerConfigsRouter } from './routers/agentTriggerConfigs.js'; import { authRouter } from './routers/auth.js'; import { credentialsRouter } from './routers/credentials.js'; import { defaultsRouter } from './routers/defaults.js'; @@ -21,6 +22,7 @@ export const appRouter = router({ credentials: credentialsRouter, agentConfigs: agentConfigsRouter, agentDefinitions: agentDefinitionsRouter, + agentTriggerConfigs: agentTriggerConfigsRouter, prompts: promptsRouter, webhooks: webhooksRouter, webhookLogs: webhookLogsRouter, diff --git a/src/api/routers/agentTriggerConfigs.ts b/src/api/routers/agentTriggerConfigs.ts new file mode 100644 index 00000000..d1dea8fc --- /dev/null +++ b/src/api/routers/agentTriggerConfigs.ts @@ -0,0 +1,155 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { + deleteTriggerConfig, + getTriggerConfig, + getTriggerConfigById, + getTriggerConfigsByProject, + getTriggerConfigsByProjectAndAgent, + updateTriggerConfig, + upsertTriggerConfig, +} from '../../db/repositories/agentTriggerConfigsRepository.js'; +import { protectedProcedure, router } from '../trpc.js'; +import { verifyProjectOrgAccess } from './_shared/projectAccess.js'; + +export const agentTriggerConfigsRouter = router({ + /** + * List all trigger configs for a project. + */ + listByProject: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); + return getTriggerConfigsByProject(input.projectId); + }), + + /** + * List trigger configs for a specific agent in a project. + */ + listByProjectAndAgent: protectedProcedure + .input(z.object({ projectId: z.string(), agentType: z.string() })) + .query(async ({ ctx, input }) => { + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); + return getTriggerConfigsByProjectAndAgent(input.projectId, input.agentType); + }), + + /** + * Get a specific trigger config. + */ + get: protectedProcedure + .input( + z.object({ + projectId: z.string(), + agentType: z.string(), + triggerEvent: z.string(), + }), + ) + .query(async ({ ctx, input }) => { + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); + return getTriggerConfig(input.projectId, input.agentType, input.triggerEvent); + }), + + /** + * Create or update a trigger config. + */ + upsert: protectedProcedure + .input( + z.object({ + projectId: z.string(), + agentType: z.string(), + triggerEvent: z.string(), + enabled: z.boolean().optional(), + parameters: z.record(z.unknown()).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); + return upsertTriggerConfig({ + projectId: input.projectId, + agentType: input.agentType, + triggerEvent: input.triggerEvent, + enabled: input.enabled, + parameters: input.parameters, + }); + }), + + /** + * Update an existing trigger config by ID. + */ + update: protectedProcedure + .input( + z.object({ + id: z.number(), + enabled: z.boolean().optional(), + parameters: z.record(z.unknown()).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + // Verify ownership + const config = await getTriggerConfigById(input.id); + if (!config) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + await verifyProjectOrgAccess(config.projectId, ctx.effectiveOrgId); + + const result = await updateTriggerConfig(input.id, { + enabled: input.enabled, + parameters: input.parameters, + }); + if (!result) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + return result; + }), + + /** + * Delete a trigger config by ID. + */ + delete: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + // Verify ownership + const config = await getTriggerConfigById(input.id); + if (!config) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + await verifyProjectOrgAccess(config.projectId, ctx.effectiveOrgId); + + await deleteTriggerConfig(input.id); + }), + + /** + * Bulk update trigger configs for a project. + * This is optimized for the dashboard where we update multiple triggers at once. + */ + bulkUpsert: protectedProcedure + .input( + z.object({ + projectId: z.string(), + configs: z.array( + z.object({ + agentType: z.string(), + triggerEvent: z.string(), + enabled: z.boolean().optional(), + parameters: z.record(z.unknown()).optional(), + }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); + + const results = await Promise.all( + input.configs.map((config) => + upsertTriggerConfig({ + projectId: input.projectId, + agentType: config.agentType, + triggerEvent: config.triggerEvent, + enabled: config.enabled, + parameters: config.parameters, + }), + ), + ); + return results; + }), +}); diff --git a/src/db/migrations/0024_agent_trigger_configs.sql b/src/db/migrations/0024_agent_trigger_configs.sql new file mode 100644 index 00000000..637a1a55 --- /dev/null +++ b/src/db/migrations/0024_agent_trigger_configs.sql @@ -0,0 +1,20 @@ +-- Agent Trigger Configs table +-- Stores per-project trigger configurations for agents +CREATE TABLE IF NOT EXISTS agent_trigger_configs ( + id SERIAL PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + agent_type TEXT NOT NULL, + trigger_event TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + parameters JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Unique constraint: one config per project/agent/trigger combination +CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_trigger_configs_project_agent_event + ON agent_trigger_configs(project_id, agent_type, trigger_event); + +-- Index for efficient lookups by project +CREATE INDEX IF NOT EXISTS idx_agent_trigger_configs_project_id + ON agent_trigger_configs(project_id); diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 523f4805..9aa6f7b6 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1758000000000, "tag": "0023_move_prompts_to_definitions", "breakpoints": false + }, + { + "idx": 24, + "version": "7", + "when": 1759000000000, + "tag": "0024_agent_trigger_configs", + "breakpoints": false } ] } diff --git a/src/db/repositories/agentTriggerConfigsRepository.ts b/src/db/repositories/agentTriggerConfigsRepository.ts new file mode 100644 index 00000000..15260b50 --- /dev/null +++ b/src/db/repositories/agentTriggerConfigsRepository.ts @@ -0,0 +1,230 @@ +import { and, eq } from 'drizzle-orm'; +import { getDb } from '../client.js'; +import { agentTriggerConfigs } from '../schema/index.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface AgentTriggerConfig { + id: number; + projectId: string; + agentType: string; + triggerEvent: string; + enabled: boolean; + parameters: Record; + createdAt: Date | null; + updatedAt: Date | null; +} + +export interface CreateTriggerConfigInput { + projectId: string; + agentType: string; + triggerEvent: string; + enabled?: boolean; + parameters?: Record; +} + +export interface UpdateTriggerConfigInput { + enabled?: boolean; + parameters?: Record; +} + +// ============================================================================ +// Repository Functions +// ============================================================================ + +/** + * Get a specific trigger config by ID. + */ +export async function getTriggerConfigById(id: number): Promise { + const db = getDb(); + const [row] = await db.select().from(agentTriggerConfigs).where(eq(agentTriggerConfigs.id, id)); + return row ? mapRowToConfig(row) : null; +} + +/** + * Get a specific trigger config by project, agent type, and trigger event. + */ +export async function getTriggerConfig( + projectId: string, + agentType: string, + triggerEvent: string, +): Promise { + const db = getDb(); + const [row] = await db + .select() + .from(agentTriggerConfigs) + .where( + and( + eq(agentTriggerConfigs.projectId, projectId), + eq(agentTriggerConfigs.agentType, agentType), + eq(agentTriggerConfigs.triggerEvent, triggerEvent), + ), + ); + return row ? mapRowToConfig(row) : null; +} + +/** + * Get all trigger configs for a project. + */ +export async function getTriggerConfigsByProject(projectId: string): Promise { + const db = getDb(); + const rows = await db + .select() + .from(agentTriggerConfigs) + .where(eq(agentTriggerConfigs.projectId, projectId)); + return rows.map(mapRowToConfig); +} + +/** + * Get all trigger configs for a specific agent in a project. + */ +export async function getTriggerConfigsByProjectAndAgent( + projectId: string, + agentType: string, +): Promise { + const db = getDb(); + const rows = await db + .select() + .from(agentTriggerConfigs) + .where( + and( + eq(agentTriggerConfigs.projectId, projectId), + eq(agentTriggerConfigs.agentType, agentType), + ), + ); + return rows.map(mapRowToConfig); +} + +/** + * Create or update a trigger config (upsert). + */ +export async function upsertTriggerConfig( + input: CreateTriggerConfigInput, +): Promise { + const db = getDb(); + const [row] = await db + .insert(agentTriggerConfigs) + .values({ + projectId: input.projectId, + agentType: input.agentType, + triggerEvent: input.triggerEvent, + enabled: input.enabled ?? true, + parameters: input.parameters ?? {}, + }) + .onConflictDoUpdate({ + target: [ + agentTriggerConfigs.projectId, + agentTriggerConfigs.agentType, + agentTriggerConfigs.triggerEvent, + ], + set: { + enabled: input.enabled ?? true, + parameters: input.parameters ?? {}, + updatedAt: new Date(), + }, + }) + .returning(); + return mapRowToConfig(row); +} + +/** + * Update an existing trigger config by ID. + */ +export async function updateTriggerConfig( + id: number, + input: UpdateTriggerConfigInput, +): Promise { + const db = getDb(); + const [row] = await db + .update(agentTriggerConfigs) + .set({ + ...(input.enabled !== undefined && { enabled: input.enabled }), + ...(input.parameters !== undefined && { parameters: input.parameters }), + updatedAt: new Date(), + }) + .where(eq(agentTriggerConfigs.id, id)) + .returning(); + return row ? mapRowToConfig(row) : null; +} + +/** + * Delete a trigger config by ID. + */ +export async function deleteTriggerConfig(id: number): Promise { + const db = getDb(); + const result = await db.delete(agentTriggerConfigs).where(eq(agentTriggerConfigs.id, id)); + return (result.rowCount ?? 0) > 0; +} + +/** + * Delete all trigger configs for a project. + */ +export async function deleteTriggerConfigsByProject(projectId: string): Promise { + const db = getDb(); + const result = await db + .delete(agentTriggerConfigs) + .where(eq(agentTriggerConfigs.projectId, projectId)); + return result.rowCount ?? 0; +} + +/** + * Bulk upsert trigger configs. + * Uses individual upserts in a transaction to ensure each config's values are used correctly. + */ +export async function bulkUpsertTriggerConfigs( + configs: CreateTriggerConfigInput[], +): Promise { + if (configs.length === 0) return []; + + const db = getDb(); + const results: AgentTriggerConfig[] = []; + + await db.transaction(async (tx) => { + for (const config of configs) { + const [row] = await tx + .insert(agentTriggerConfigs) + .values({ + projectId: config.projectId, + agentType: config.agentType, + triggerEvent: config.triggerEvent, + enabled: config.enabled ?? true, + parameters: config.parameters ?? {}, + }) + .onConflictDoUpdate({ + target: [ + agentTriggerConfigs.projectId, + agentTriggerConfigs.agentType, + agentTriggerConfigs.triggerEvent, + ], + set: { + enabled: config.enabled ?? true, + parameters: config.parameters ?? {}, + updatedAt: new Date(), + }, + }) + .returning(); + results.push(mapRowToConfig(row)); + } + }); + + return results; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function mapRowToConfig(row: typeof agentTriggerConfigs.$inferSelect): AgentTriggerConfig { + return { + id: row.id, + projectId: row.projectId, + agentType: row.agentType, + triggerEvent: row.triggerEvent, + enabled: row.enabled, + parameters: (row.parameters ?? {}) as Record, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} diff --git a/src/db/schema/agentTriggerConfigs.ts b/src/db/schema/agentTriggerConfigs.ts new file mode 100644 index 00000000..385c03e3 --- /dev/null +++ b/src/db/schema/agentTriggerConfigs.ts @@ -0,0 +1,35 @@ +import { boolean, jsonb, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { projects } from './projects.js'; + +/** + * Per-project trigger configurations for agents. + * Stores enabled/disabled state and parameter overrides for each trigger. + */ +export const agentTriggerConfigs = pgTable( + 'agent_trigger_configs', + { + id: serial('id').primaryKey(), + projectId: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + /** Agent type (e.g., 'implementation', 'review', 'email-joke') */ + agentType: text('agent_type').notNull(), + /** Trigger event identifier (e.g., 'pm:card-moved', 'scm:check-suite-success') */ + triggerEvent: text('trigger_event').notNull(), + /** Whether this trigger is enabled for this project/agent */ + enabled: boolean('enabled').notNull().default(true), + /** Trigger-specific parameters (e.g., { targetList: 'todo', senderEmail: 'user@example.com' }) */ + parameters: jsonb('parameters').notNull().default({}), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + uniqueIndex('uq_agent_trigger_configs_project_agent_event').on( + table.projectId, + table.agentType, + table.triggerEvent, + ), + ], +); diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index d5bf839c..91b3803b 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -3,6 +3,7 @@ export { cascadeDefaults } from './defaults.js'; export { organizations } from './organizations.js'; export { agentConfigs } from './agentConfigs.js'; export { agentDefinitions } from './agentDefinitions.js'; +export { agentTriggerConfigs } from './agentTriggerConfigs.js'; export { integrationCredentials, projectIntegrations } from './integrations.js'; export { projects } from './projects.js'; export { agentRunLlmCalls, agentRunLogs, agentRuns, debugAnalyses } from './runs.js'; diff --git a/src/triggers/config-resolver.ts b/src/triggers/config-resolver.ts new file mode 100644 index 00000000..9479fcdd --- /dev/null +++ b/src/triggers/config-resolver.ts @@ -0,0 +1,256 @@ +/** + * Trigger configuration resolver. + * Merges agent definition defaults with project-specific overrides from the database. + */ + +import { resolveAgentDefinition } from '../agents/definitions/index.js'; +import type { SupportedTrigger } from '../agents/definitions/schema.js'; +import { + type AgentTriggerConfig, + getTriggerConfig, + getTriggerConfigsByProjectAndAgent, +} from '../db/repositories/agentTriggerConfigsRepository.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ResolvedTriggerConfig { + /** Trigger event identifier (e.g., 'pm:card-moved') */ + event: string; + /** Human-readable label */ + label: string; + /** Description for help text */ + description?: string; + /** Whether this trigger is enabled */ + enabled: boolean; + /** Resolved parameters (defaults merged with overrides) */ + parameters: Record; + /** Provider filter (e.g., ['trello']) */ + providers?: string[]; + /** Whether this config has been customized (has DB override) */ + isCustomized: boolean; +} + +// ============================================================================ +// Resolution Functions +// ============================================================================ + +/** + * Resolve all trigger configurations for an agent in a project. + * Merges definition defaults with project-specific overrides. + */ +export async function resolveTriggerConfigs( + projectId: string, + agentType: string, +): Promise { + // Get definition triggers + const definition = await resolveAgentDefinition(agentType); + if (!definition) { + return []; + } + + const definitionTriggers = definition.triggers ?? []; + if (definitionTriggers.length === 0) { + return []; + } + + // Get project overrides + const dbConfigs = await getTriggerConfigsByProjectAndAgent(projectId, agentType); + const dbConfigMap = new Map(); + for (const config of dbConfigs) { + dbConfigMap.set(config.triggerEvent, config); + } + + // Merge definition defaults with overrides + return definitionTriggers.map((trigger) => { + const override = dbConfigMap.get(trigger.event); + return mergeTriggerConfig(trigger, override); + }); +} + +/** + * Check if a specific trigger is enabled for a project/agent combination. + * This is the primary function for trigger handlers to use. + */ +export async function isTriggerEnabled( + projectId: string, + agentType: string, + triggerEvent: string, +): Promise { + // First check DB override + const dbConfig = await getTriggerConfig(projectId, agentType, triggerEvent); + if (dbConfig) { + return dbConfig.enabled; + } + + // Fall back to definition default + const definition = await resolveAgentDefinition(agentType); + if (!definition) { + return false; + } + + const trigger = definition.triggers?.find((t) => t.event === triggerEvent); + if (!trigger) { + return false; + } + + return trigger.defaultEnabled; +} + +/** + * Get trigger parameters for a specific trigger. + * Returns merged parameters (definition defaults + project overrides). + */ +export async function getTriggerParameters( + projectId: string, + agentType: string, + triggerEvent: string, +): Promise> { + const definition = await resolveAgentDefinition(agentType); + if (!definition) { + return {}; + } + + const trigger = definition.triggers?.find((t) => t.event === triggerEvent); + if (!trigger) { + return {}; + } + + // Build default parameters from definition + const defaultParams: Record = {}; + for (const param of trigger.parameters ?? []) { + if (param.defaultValue !== undefined) { + defaultParams[param.name] = param.defaultValue; + } + } + + // Get DB override + const dbConfig = await getTriggerConfig(projectId, agentType, triggerEvent); + if (!dbConfig) { + return defaultParams; + } + + // Merge: DB overrides take precedence + return { ...defaultParams, ...dbConfig.parameters }; +} + +/** + * Get a single resolved trigger configuration. + */ +export async function getResolvedTriggerConfig( + projectId: string, + agentType: string, + triggerEvent: string, +): Promise { + const definition = await resolveAgentDefinition(agentType); + if (!definition) { + return null; + } + + const trigger = definition.triggers?.find((t) => t.event === triggerEvent); + if (!trigger) { + return null; + } + + const dbConfig = await getTriggerConfig(projectId, agentType, triggerEvent); + return mergeTriggerConfig(trigger, dbConfig ?? undefined); +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function mergeTriggerConfig( + trigger: SupportedTrigger, + override?: AgentTriggerConfig, +): ResolvedTriggerConfig { + // Build default parameters from definition + const defaultParams: Record = {}; + for (const param of trigger.parameters ?? []) { + if (param.defaultValue !== undefined) { + defaultParams[param.name] = param.defaultValue; + } + } + + // Merge parameters + const mergedParams = override ? { ...defaultParams, ...override.parameters } : defaultParams; + + return { + event: trigger.event, + label: trigger.label, + description: trigger.description, + enabled: override ? override.enabled : trigger.defaultEnabled, + parameters: mergedParams, + providers: trigger.providers, + isCustomized: !!override, + }; +} + +// ============================================================================ +// Legacy Fallback Support +// ============================================================================ + +/** + * Check if a trigger is enabled using the legacy project_integrations.triggers config. + * This is a fallback for projects that haven't migrated to the new system. + * + * @param legacyTriggers - The triggers JSONB from project_integrations + * @param triggerEvent - The new-style event name (e.g., 'pm:card-moved') + * @param legacyKey - The legacy trigger config key (e.g., 'cardMovedToTodo') + * @param defaultValue - Default value if not found + */ +export function resolveLegacyTriggerEnabled( + legacyTriggers: Record | null | undefined, + _triggerEvent: string, + legacyKey: string, + defaultValue: boolean, +): boolean { + if (!legacyTriggers) { + return defaultValue; + } + + const value = legacyTriggers[legacyKey]; + if (typeof value === 'boolean') { + return value; + } + + return defaultValue; +} + +/** + * Map from new trigger event names to legacy trigger config keys. + * Used for backward compatibility during migration. + */ +export const LEGACY_TRIGGER_KEY_MAP: Record = { + // PM triggers + 'pm:card-moved': 'cardMovedToTodo', // varies by agent + 'pm:issue-transitioned': 'issueTransitioned', + 'pm:label-added': 'readyToProcessLabel', + 'pm:comment-mention': 'commentMention', + // SCM triggers + 'scm:check-suite-success': 'checkSuiteSuccess', + 'scm:check-suite-failure': 'checkSuiteFailure', + 'scm:pr-review-submitted': 'prReviewSubmitted', + 'scm:pr-comment-mention': 'prCommentMention', + 'scm:review-requested': 'reviewRequested', + 'scm:pr-opened': 'prOpened', + // Email triggers + 'email:received': 'emailReceived', +}; + +/** + * Get the legacy trigger key for an agent-specific card-moved trigger. + */ +export function getLegacyCardMovedKey(agentType: string): string { + switch (agentType) { + case 'splitting': + return 'cardMovedToSplitting'; + case 'planning': + return 'cardMovedToPlanning'; + case 'implementation': + return 'cardMovedToTodo'; + default: + return 'cardMovedToTodo'; + } +} diff --git a/src/triggers/shared/integration-validation.ts b/src/triggers/shared/integration-validation.ts index 031d2043..7d6e63ae 100644 --- a/src/triggers/shared/integration-validation.ts +++ b/src/triggers/shared/integration-validation.ts @@ -2,7 +2,7 @@ * Pre-flight integration validation for agents. * * Validates that all required integrations are configured before an agent runs. - * Integrations are derived from agent capabilities - no separate declaration needed. + * Integrations can be explicitly declared in the agent definition, or derived from capabilities. */ import { deriveIntegrations } from '../../agents/capabilities/index.js'; @@ -34,10 +34,23 @@ export interface DerivedIntegrations { } /** - * Get integration requirements for an agent, derived from capabilities. + * Get integration requirements for an agent. + * + * Uses explicit integrations from the definition if available, + * otherwise falls back to deriving from capabilities. */ export async function getIntegrationRequirements(agentType: string): Promise { const def = await resolveAgentDefinition(agentType); + + // Prefer explicit integrations if defined + if (def.integrations) { + return { + required: def.integrations.required ?? [], + optional: def.integrations.optional ?? [], + }; + } + + // Fall back to deriving from capabilities (backward compatibility) return deriveIntegrations(def.capabilities.required, def.capabilities.optional); } diff --git a/tests/unit/agents/definitions/schema.test.ts b/tests/unit/agents/definitions/schema.test.ts index 39c121bf..e029c66f 100644 --- a/tests/unit/agents/definitions/schema.test.ts +++ b/tests/unit/agents/definitions/schema.test.ts @@ -1,5 +1,317 @@ import { describe, expect, it } from 'vitest'; -import { AgentDefinitionSchema } from '../../../../src/agents/definitions/schema.js'; +import { + AgentDefinitionSchema, + IntegrationRequirementsSchema, + KnownProviderSchema, + SupportedTriggerSchema, + TriggerParameterSchema, +} from '../../../../src/agents/definitions/schema.js'; + +// ============================================================================ +// TriggerParameterSchema Tests +// ============================================================================ + +describe('TriggerParameterSchema', () => { + it('parses a valid string parameter', () => { + const param = { + name: 'senderEmail', + type: 'string', + label: 'Sender Email', + }; + const result = TriggerParameterSchema.safeParse(param); + expect(result.success).toBe(true); + }); + + it('parses a valid select parameter with options', () => { + const param = { + name: 'targetList', + type: 'select', + label: 'Target List', + options: ['todo', 'planning', 'splitting'], + defaultValue: 'todo', + }; + const result = TriggerParameterSchema.safeParse(param); + expect(result.success).toBe(true); + }); + + it('parses a valid boolean parameter', () => { + const param = { + name: 'enabled', + type: 'boolean', + label: 'Enabled', + defaultValue: true, + }; + const result = TriggerParameterSchema.safeParse(param); + expect(result.success).toBe(true); + }); + + it('rejects defaultValue type mismatch (boolean expected, string given)', () => { + const param = { + name: 'enabled', + type: 'boolean', + label: 'Enabled', + defaultValue: 'true', // Should be boolean + }; + const result = TriggerParameterSchema.safeParse(param); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain( + 'defaultValue type must match parameter type', + ); + } + }); + + it('rejects defaultValue type mismatch (string expected, boolean given)', () => { + const param = { + name: 'target', + type: 'select', + label: 'Target', + options: ['a', 'b'], + defaultValue: true, // Should be string + }; + const result = TriggerParameterSchema.safeParse(param); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain( + 'defaultValue type must match parameter type', + ); + } + }); + + it('rejects required parameter with defaultValue', () => { + const param = { + name: 'target', + type: 'select', + label: 'Target', + options: ['a', 'b'], + defaultValue: 'a', + required: true, // Contradiction + }; + const result = TriggerParameterSchema.safeParse(param); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain( + 'Parameter with defaultValue cannot be required', + ); + } + }); + + it('allows required parameter without defaultValue', () => { + const param = { + name: 'target', + type: 'select', + label: 'Target', + options: ['a', 'b'], + required: true, + }; + const result = TriggerParameterSchema.safeParse(param); + expect(result.success).toBe(true); + }); +}); + +// ============================================================================ +// SupportedTriggerSchema Tests +// ============================================================================ + +describe('SupportedTriggerSchema', () => { + it('parses a valid trigger with event format pm:card-moved', () => { + const trigger = { + event: 'pm:card-moved', + label: 'Card Moved', + }; + const result = SupportedTriggerSchema.safeParse(trigger); + expect(result.success).toBe(true); + }); + + it('parses a valid trigger with event format scm:check-suite-success', () => { + const trigger = { + event: 'scm:check-suite-success', + label: 'CI Passed', + providers: ['github'], + }; + const result = SupportedTriggerSchema.safeParse(trigger); + expect(result.success).toBe(true); + }); + + it('parses a valid trigger with email:received', () => { + const trigger = { + event: 'email:received', + label: 'Email Received', + }; + const result = SupportedTriggerSchema.safeParse(trigger); + expect(result.success).toBe(true); + }); + + it('parses a valid trigger with sms:received', () => { + const trigger = { + event: 'sms:received', + label: 'SMS Received', + }; + const result = SupportedTriggerSchema.safeParse(trigger); + expect(result.success).toBe(true); + }); + + it('rejects invalid event format (missing category)', () => { + const trigger = { + event: 'card-moved', // Missing category prefix + label: 'Card Moved', + }; + const result = SupportedTriggerSchema.safeParse(trigger); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('Event must be in format'); + } + }); + + it('rejects invalid event format (invalid category)', () => { + const trigger = { + event: 'invalid:card-moved', // Invalid category + label: 'Card Moved', + }; + const result = SupportedTriggerSchema.safeParse(trigger); + expect(result.success).toBe(false); + }); + + it('rejects invalid event format (uppercase)', () => { + const trigger = { + event: 'PM:Card-Moved', // Must be lowercase + label: 'Card Moved', + }; + const result = SupportedTriggerSchema.safeParse(trigger); + expect(result.success).toBe(false); + }); + + it('rejects invalid provider', () => { + const trigger = { + event: 'pm:card-moved', + label: 'Card Moved', + providers: ['invalid-provider'], // Unknown provider + }; + const result = SupportedTriggerSchema.safeParse(trigger); + expect(result.success).toBe(false); + }); + + it('accepts all valid providers', () => { + const trigger = { + event: 'pm:card-moved', + label: 'Card Moved', + providers: ['trello', 'jira', 'github', 'imap', 'gmail', 'twilio'], + }; + const result = SupportedTriggerSchema.safeParse(trigger); + expect(result.success).toBe(true); + }); + + it('parses trigger with parameters', () => { + const trigger = { + event: 'pm:card-moved', + label: 'Card Moved to Todo', + parameters: [ + { + name: 'targetList', + type: 'select', + label: 'Target List', + options: ['todo'], + defaultValue: 'todo', + }, + ], + }; + const result = SupportedTriggerSchema.safeParse(trigger); + expect(result.success).toBe(true); + }); +}); + +// ============================================================================ +// KnownProviderSchema Tests +// ============================================================================ + +describe('KnownProviderSchema', () => { + it('accepts trello', () => { + expect(KnownProviderSchema.safeParse('trello').success).toBe(true); + }); + + it('accepts jira', () => { + expect(KnownProviderSchema.safeParse('jira').success).toBe(true); + }); + + it('accepts github', () => { + expect(KnownProviderSchema.safeParse('github').success).toBe(true); + }); + + it('accepts imap', () => { + expect(KnownProviderSchema.safeParse('imap').success).toBe(true); + }); + + it('accepts gmail', () => { + expect(KnownProviderSchema.safeParse('gmail').success).toBe(true); + }); + + it('accepts twilio', () => { + expect(KnownProviderSchema.safeParse('twilio').success).toBe(true); + }); + + it('rejects unknown providers', () => { + expect(KnownProviderSchema.safeParse('gitlab').success).toBe(false); + expect(KnownProviderSchema.safeParse('asana').success).toBe(false); + }); +}); + +// ============================================================================ +// IntegrationRequirementsSchema Tests +// ============================================================================ + +describe('IntegrationRequirementsSchema', () => { + it('parses valid integration requirements', () => { + const requirements = { + required: ['pm', 'scm'], + optional: ['email'], + }; + const result = IntegrationRequirementsSchema.safeParse(requirements); + expect(result.success).toBe(true); + }); + + it('defaults to empty arrays', () => { + const result = IntegrationRequirementsSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.required).toEqual([]); + expect(result.data.optional).toEqual([]); + } + }); + + it('rejects overlapping required and optional integrations', () => { + const requirements = { + required: ['pm', 'scm'], + optional: ['pm'], // pm is in both + }; + const result = IntegrationRequirementsSchema.safeParse(requirements); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('cannot be both required and optional'); + } + }); + + it('rejects invalid integration categories', () => { + const requirements = { + required: ['invalid'], + optional: [], + }; + const result = IntegrationRequirementsSchema.safeParse(requirements); + expect(result.success).toBe(false); + }); + + it('accepts all valid integration categories', () => { + const requirements = { + required: ['pm', 'scm'], + optional: ['email', 'sms'], + }; + const result = IntegrationRequirementsSchema.safeParse(requirements); + expect(result.success).toBe(true); + }); +}); + +// ============================================================================ +// AgentDefinitionSchema Tests +// ============================================================================ describe('AgentDefinitionSchema', () => { const validDefinition = { @@ -207,4 +519,75 @@ describe('AgentDefinitionSchema', () => { expect(result.data.capabilities.optional).toEqual(['pm:read', 'pm:write']); } }); + + it('accepts definition with valid triggers', () => { + const withTriggers = { + ...validDefinition, + triggers: [ + { + event: 'pm:card-moved', + label: 'Card Moved to Todo', + defaultEnabled: true, + providers: ['trello'], + parameters: [ + { + name: 'targetList', + type: 'select', + label: 'Target List', + options: ['todo'], + defaultValue: 'todo', + }, + ], + }, + ], + }; + const result = AgentDefinitionSchema.safeParse(withTriggers); + expect(result.success).toBe(true); + }); + + it('accepts definition with valid integrations', () => { + const withIntegrations = { + ...validDefinition, + integrations: { + required: ['pm', 'scm'], + optional: ['email'], + }, + }; + const result = AgentDefinitionSchema.safeParse(withIntegrations); + expect(result.success).toBe(true); + }); + + it('rejects definition with invalid trigger event format', () => { + const badTrigger = { + ...validDefinition, + triggers: [ + { + event: 'invalid-event-format', // Missing category prefix + label: 'Bad Trigger', + }, + ], + }; + const result = AgentDefinitionSchema.safeParse(badTrigger); + expect(result.success).toBe(false); + }); + + it('rejects definition with overlapping integrations', () => { + const overlappingIntegrations = { + ...validDefinition, + integrations: { + required: ['pm'], + optional: ['pm'], // Overlaps with required + }, + }; + const result = AgentDefinitionSchema.safeParse(overlappingIntegrations); + expect(result.success).toBe(false); + }); + + it('defaults triggers to empty array when not provided', () => { + const result = AgentDefinitionSchema.safeParse(validDefinition); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.triggers).toEqual([]); + } + }); }); diff --git a/tests/unit/api/routers/agentDefinitions.test.ts b/tests/unit/api/routers/agentDefinitions.test.ts index 844033ea..9f20d0eb 100644 --- a/tests/unit/api/routers/agentDefinitions.test.ts +++ b/tests/unit/api/routers/agentDefinitions.test.ts @@ -67,6 +67,7 @@ function createMockDefinition(overrides?: Partial): AgentDefini required: ['fs:read', 'fs:write', 'shell:exec', 'session:ctrl', 'scm:pr'], optional: [], }, + triggers: [], strategies: { contextPipeline: ['directoryListing'], }, diff --git a/tests/unit/api/routers/agentTriggerConfigs.test.ts b/tests/unit/api/routers/agentTriggerConfigs.test.ts new file mode 100644 index 00000000..9ef398c1 --- /dev/null +++ b/tests/unit/api/routers/agentTriggerConfigs.test.ts @@ -0,0 +1,316 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TRPCContext } from '../../../../src/api/trpc.js'; +import { createMockUser } from '../../../helpers/factories.js'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockGetTriggerConfigById = vi.fn(); +const mockGetTriggerConfig = vi.fn(); +const mockGetTriggerConfigsByProject = vi.fn(); +const mockGetTriggerConfigsByProjectAndAgent = vi.fn(); +const mockUpsertTriggerConfig = vi.fn(); +const mockUpdateTriggerConfig = vi.fn(); +const mockDeleteTriggerConfig = vi.fn(); + +vi.mock('../../../../src/db/repositories/agentTriggerConfigsRepository.js', () => ({ + getTriggerConfigById: (...args: unknown[]) => mockGetTriggerConfigById(...args), + getTriggerConfig: (...args: unknown[]) => mockGetTriggerConfig(...args), + getTriggerConfigsByProject: (...args: unknown[]) => mockGetTriggerConfigsByProject(...args), + getTriggerConfigsByProjectAndAgent: (...args: unknown[]) => + mockGetTriggerConfigsByProjectAndAgent(...args), + upsertTriggerConfig: (...args: unknown[]) => mockUpsertTriggerConfig(...args), + updateTriggerConfig: (...args: unknown[]) => mockUpdateTriggerConfig(...args), + deleteTriggerConfig: (...args: unknown[]) => mockDeleteTriggerConfig(...args), +})); + +const mockVerifyProjectOrgAccess = vi.fn(); + +vi.mock('../../../../src/api/routers/_shared/projectAccess.js', () => ({ + verifyProjectOrgAccess: (...args: unknown[]) => mockVerifyProjectOrgAccess(...args), +})); + +import { agentTriggerConfigsRouter } from '../../../../src/api/routers/agentTriggerConfigs.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createCaller(ctx: TRPCContext) { + return agentTriggerConfigsRouter.createCaller(ctx); +} + +const mockUser = createMockUser(); + +function createMockConfig(overrides?: Record) { + return { + id: 1, + projectId: 'test-project', + agentType: 'implementation', + triggerEvent: 'pm:card-moved', + enabled: true, + parameters: {}, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('agentTriggerConfigsRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockVerifyProjectOrgAccess.mockResolvedValue(undefined); + }); + + // ===================================================================== + // listByProject + // ===================================================================== + describe('listByProject', () => { + it('returns all trigger configs for a project', async () => { + const configs = [ + createMockConfig(), + createMockConfig({ id: 2, triggerEvent: 'pm:label-added' }), + ]; + mockGetTriggerConfigsByProject.mockResolvedValue(configs); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.listByProject({ projectId: 'test-project' }); + + expect(result).toEqual(configs); + expect(mockVerifyProjectOrgAccess).toHaveBeenCalledWith('test-project', mockUser.orgId); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect(caller.listByProject({ projectId: 'test-project' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); + + // ===================================================================== + // listByProjectAndAgent + // ===================================================================== + describe('listByProjectAndAgent', () => { + it('returns trigger configs for a specific agent', async () => { + const configs = [createMockConfig()]; + mockGetTriggerConfigsByProjectAndAgent.mockResolvedValue(configs); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.listByProjectAndAgent({ + projectId: 'test-project', + agentType: 'implementation', + }); + + expect(result).toEqual(configs); + expect(mockGetTriggerConfigsByProjectAndAgent).toHaveBeenCalledWith( + 'test-project', + 'implementation', + ); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect( + caller.listByProjectAndAgent({ projectId: 'test-project', agentType: 'implementation' }), + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); + + // ===================================================================== + // get + // ===================================================================== + describe('get', () => { + it('returns a specific trigger config', async () => { + const config = createMockConfig(); + mockGetTriggerConfig.mockResolvedValue(config); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.get({ + projectId: 'test-project', + agentType: 'implementation', + triggerEvent: 'pm:card-moved', + }); + + expect(result).toEqual(config); + }); + + it('returns null when config not found', async () => { + mockGetTriggerConfig.mockResolvedValue(null); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.get({ + projectId: 'test-project', + agentType: 'implementation', + triggerEvent: 'pm:card-moved', + }); + + expect(result).toBeNull(); + }); + }); + + // ===================================================================== + // upsert + // ===================================================================== + describe('upsert', () => { + it('creates or updates a trigger config', async () => { + const config = createMockConfig(); + mockUpsertTriggerConfig.mockResolvedValue(config); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.upsert({ + projectId: 'test-project', + agentType: 'implementation', + triggerEvent: 'pm:card-moved', + enabled: true, + parameters: { targetList: 'todo' }, + }); + + expect(result).toEqual(config); + expect(mockUpsertTriggerConfig).toHaveBeenCalledWith({ + projectId: 'test-project', + agentType: 'implementation', + triggerEvent: 'pm:card-moved', + enabled: true, + parameters: { targetList: 'todo' }, + }); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect( + caller.upsert({ + projectId: 'test-project', + agentType: 'implementation', + triggerEvent: 'pm:card-moved', + }), + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); + + // ===================================================================== + // update + // ===================================================================== + describe('update', () => { + it('updates an existing trigger config by ID', async () => { + const existing = createMockConfig(); + const updated = { ...existing, enabled: false }; + mockGetTriggerConfigById.mockResolvedValue(existing); + mockUpdateTriggerConfig.mockResolvedValue(updated); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.update({ id: 1, enabled: false }); + + expect(result).toEqual(updated); + expect(mockUpdateTriggerConfig).toHaveBeenCalledWith(1, { + enabled: false, + parameters: undefined, + }); + }); + + it('throws NOT_FOUND when config does not exist', async () => { + mockGetTriggerConfigById.mockResolvedValue(null); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.update({ id: 999, enabled: false })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws NOT_FOUND when update returns null', async () => { + mockGetTriggerConfigById.mockResolvedValue(createMockConfig()); + mockUpdateTriggerConfig.mockResolvedValue(null); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.update({ id: 1, enabled: false })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect(caller.update({ id: 1, enabled: false })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); + + // ===================================================================== + // delete + // ===================================================================== + describe('delete', () => { + it('deletes a trigger config by ID', async () => { + mockGetTriggerConfigById.mockResolvedValue(createMockConfig()); + mockDeleteTriggerConfig.mockResolvedValue(true); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await caller.delete({ id: 1 }); + + expect(mockDeleteTriggerConfig).toHaveBeenCalledWith(1); + }); + + it('throws NOT_FOUND when config does not exist', async () => { + mockGetTriggerConfigById.mockResolvedValue(null); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.delete({ id: 999 })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect(caller.delete({ id: 1 })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); + + // ===================================================================== + // bulkUpsert + // ===================================================================== + describe('bulkUpsert', () => { + it('bulk upserts multiple trigger configs', async () => { + const configs = [ + createMockConfig(), + createMockConfig({ id: 2, triggerEvent: 'pm:label-added' }), + ]; + mockUpsertTriggerConfig.mockImplementation((input) => + Promise.resolve(createMockConfig({ triggerEvent: input.triggerEvent })), + ); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.bulkUpsert({ + projectId: 'test-project', + configs: [ + { agentType: 'implementation', triggerEvent: 'pm:card-moved', enabled: true }, + { agentType: 'implementation', triggerEvent: 'pm:label-added', enabled: false }, + ], + }); + + expect(result).toHaveLength(2); + expect(mockUpsertTriggerConfig).toHaveBeenCalledTimes(2); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect( + caller.bulkUpsert({ + projectId: 'test-project', + configs: [], + }), + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); +}); diff --git a/web/src/lib/trigger-agent-mapping.ts b/web/src/lib/trigger-agent-mapping.ts index 573bf35f..a7dab14e 100644 --- a/web/src/lib/trigger-agent-mapping.ts +++ b/web/src/lib/trigger-agent-mapping.ts @@ -320,3 +320,48 @@ export const AGENT_LABELS: Record = { /** Agent types that use email-based trigger configuration (custom widget, not toggle-based) */ export const EMAIL_TRIGGER_AGENTS = new Set(['email-joke']); + +// ============================================================================ +// Dynamic Trigger Resolution Helpers +// ============================================================================ + +/** + * Convert a definition-based trigger to the legacy TriggerDef format. + * This allows gradual migration from hardcoded AGENT_TRIGGER_MAP to definition-based triggers. + * + * @param trigger - Trigger from agent definition + * @returns TriggerDef compatible with existing dashboard components + */ +export function definitionTriggerToTriggerDef(trigger: { + event: string; + label: string; + description?: string; + defaultEnabled: boolean; + providers?: string[]; +}): TriggerDef { + // Map event category prefix to integration category + const [category] = trigger.event.split(':') as ['pm' | 'scm' | 'email' | 'sms']; + + return { + key: trigger.event, // Use event as key for new triggers + label: trigger.label, + description: trigger.description ?? '', + defaultValue: trigger.defaultEnabled, + category: category === 'email' || category === 'sms' ? 'pm' : category, // Map email/sms to pm for UI + pmProvider: trigger.providers?.find((p) => p === 'trello' || p === 'jira') as + | 'trello' + | 'jira' + | undefined, + scmProvider: trigger.providers?.find((p) => p === 'github') as 'github' | undefined, + }; +} + +/** + * Check if an agent has definition-based triggers. + * Used to determine whether to use dynamic or hardcoded triggers. + */ +export function hasDefinitionTriggers(agentType: string): boolean { + // For now, always return false to keep using hardcoded mapping + // This will be updated when the dashboard is ready to consume definition triggers + return false && agentType !== ''; // Placeholder - suppress unused warning +} From 993a339f085ee5e56380a717f602fa48c3b1e964 Mon Sep 17 00:00:00 2001 From: zbigniew sobiecki Date: Sun, 1 Mar 2026 19:11:37 +0100 Subject: [PATCH 2/2] fix(web): add triggers field to empty definition Add `triggers: []` to EMPTY_DEFINITION in agent-definition-editor.tsx to satisfy the TypeScript type now that triggers is a required field in AgentDefinition. Co-Authored-By: Claude Opus 4.5 --- web/src/components/settings/agent-definition-editor.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/components/settings/agent-definition-editor.tsx b/web/src/components/settings/agent-definition-editor.tsx index b9a5e3da..14db0b03 100644 --- a/web/src/components/settings/agent-definition-editor.tsx +++ b/web/src/components/settings/agent-definition-editor.tsx @@ -803,6 +803,7 @@ const EMPTY_DEFINITION: AgentDefinition = { required: ['fs:read', 'session:ctrl'], optional: [], }, + triggers: [], strategies: { contextPipeline: [], },