feat(trigger): implement plugin trigger synchronization and subscription management in workflow

- Added a new event handler for syncing plugin trigger relationships when a draft workflow is synced, ensuring that the database reflects the current state of plugin triggers.
- Introduced subscription management features in the frontend, allowing users to select, add, and remove subscriptions for trigger plugins.
- Updated various components to support subscription handling, including the addition of new UI elements for subscription selection and removal.
- Enhanced internationalization support by adding new translation keys related to subscription management.

These changes improve the overall functionality and user experience of trigger plugins within workflows.
This commit is contained in:
Harry
2025-09-15 15:49:07 +08:00
parent dcf3ee6982
commit 6857bb4406
11 changed files with 352 additions and 82 deletions

View File

@@ -52,6 +52,7 @@ class QueueEvent(StrEnum):
STOP = "stop"
RETRY = "retry"
class AppQueueEvent(BaseModel):
"""
QueueEvent abstract entity

View File

@@ -4,6 +4,8 @@ from .create_document_index import handle
from .create_installed_app_when_app_created import handle
from .create_site_record_when_app_created import handle
from .delete_tool_parameters_cache_when_sync_draft_workflow import handle
from .sync_plugin_trigger_when_app_created import handle
from .sync_webhook_when_app_created import handle
from .sync_workflow_schedule_when_app_published import handle
from .update_app_dataset_join_when_app_model_config_updated import handle
from .update_app_dataset_join_when_app_published_workflow_updated import handle

View File

@@ -0,0 +1,22 @@
import logging
from events.app_event import app_draft_workflow_was_synced
from models.model import App, AppMode
from models.workflow import Workflow
from services.workflow_plugin_trigger_service import WorkflowPluginTriggerService
logger = logging.getLogger(__name__)
@app_draft_workflow_was_synced.connect
def handle(sender, synced_draft_workflow: Workflow, **kwargs):
"""
While creating a workflow or updating a workflow, we may need to sync
its plugin trigger relationships in DB.
"""
app: App = sender
if app.mode != AppMode.WORKFLOW.value:
# only handle workflow app, chatflow is not supported yet
return
WorkflowPluginTriggerService.sync_plugin_trigger_relationships(app, synced_draft_workflow)

View File

@@ -1484,7 +1484,7 @@ class WorkflowPluginTrigger(Base):
- node_id (varchar) Node ID which node in the workflow
- tenant_id (uuid) Workspace ID
- provider_id (varchar) Plugin provider ID
- trigger_name (varchar) trigger name (github_issues_trigger)
- trigger_name (varchar) trigger name
- subscription_id (varchar) Subscription ID
- created_at (timestamp) Creation time
- updated_at (timestamp) Last update time

View File

@@ -13,6 +13,7 @@ logger = logging.getLogger(__name__)
TRIGGER_DEBUG_EVENT_TTL = 300
class TriggerDebugEvent(BaseModel):
subscription_id: str
request_id: str

View File

@@ -1,17 +1,24 @@
from typing import Optional
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from core.workflow.nodes.enums import NodeType
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from models.model import App
from models.trigger import TriggerSubscription
from models.workflow import WorkflowPluginTrigger
from models.workflow import Workflow, WorkflowPluginTrigger
class WorkflowPluginTriggerService:
"""Service for managing workflow plugin triggers"""
__PLUGIN_TRIGGER_NODE_CACHE_KEY__ = "plugin_trigger_nodes"
MAX_PLUGIN_TRIGGER_NODES_PER_WORKFLOW = 5 # Maximum allowed plugin trigger nodes per workflow
@classmethod
def create_plugin_trigger(
cls,
@@ -379,3 +386,161 @@ class WorkflowPluginTriggerService:
session.commit()
return count
@classmethod
def sync_plugin_trigger_relationships(cls, app: App, workflow: Workflow):
"""
Sync plugin trigger relationships in DB.
1. Check if the workflow has any plugin trigger nodes
2. Fetch the nodes from DB, see if there were any plugin trigger records already
3. Diff the nodes and the plugin trigger records, create/update/delete the records as needed
Approach:
Frequent DB operations may cause performance issues, using Redis to cache it instead.
If any record exists, cache it.
Limits:
- Maximum 5 plugin trigger nodes per workflow
"""
class Cache(BaseModel):
"""
Cache model for plugin trigger nodes
"""
record_id: str
node_id: str
provider_id: str
trigger_name: str
subscription_id: str
# Walk nodes to find plugin triggers
nodes_in_graph = []
for node_id, node_config in workflow.walk_nodes(NodeType.TRIGGER_PLUGIN):
# Extract plugin trigger configuration from node
plugin_id = node_config.get("plugin_id", "")
provider_id = node_config.get("provider_id", "")
trigger_name = node_config.get("trigger_name", "")
subscription_id = node_config.get("subscription_id", "")
if not subscription_id:
continue
nodes_in_graph.append(
{
"node_id": node_id,
"plugin_id": plugin_id,
"provider_id": provider_id,
"trigger_name": trigger_name,
"subscription_id": subscription_id,
}
)
# Check plugin trigger node limit
if len(nodes_in_graph) > cls.MAX_PLUGIN_TRIGGER_NODES_PER_WORKFLOW:
raise ValueError(
f"Workflow exceeds maximum plugin trigger node limit. "
f"Found {len(nodes_in_graph)} plugin trigger nodes, "
f"maximum allowed is {cls.MAX_PLUGIN_TRIGGER_NODES_PER_WORKFLOW}"
)
not_found_in_cache: list[dict] = []
for node_info in nodes_in_graph:
node_id = node_info["node_id"]
# firstly check if the node exists in cache
if not redis_client.get(f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:{node_id}"):
not_found_in_cache.append(node_info)
continue
with Session(db.engine) as session:
try:
# lock the concurrent plugin trigger creation
redis_client.lock(f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:apps:{app.id}:lock", timeout=10)
# fetch the non-cached nodes from DB
all_records = session.scalars(
select(WorkflowPluginTrigger).where(
WorkflowPluginTrigger.app_id == app.id,
WorkflowPluginTrigger.tenant_id == app.tenant_id,
)
).all()
nodes_id_in_db = {node.node_id: node for node in all_records}
nodes_id_in_graph = {node["node_id"] for node in nodes_in_graph}
# get the nodes not found both in cache and DB
nodes_not_found = [
node_info for node_info in not_found_in_cache if node_info["node_id"] not in nodes_id_in_db
]
# create new plugin trigger records
for node_info in nodes_not_found:
plugin_trigger = WorkflowPluginTrigger(
app_id=app.id,
tenant_id=app.tenant_id,
node_id=node_info["node_id"],
provider_id=node_info["provider_id"],
trigger_name=node_info["trigger_name"],
subscription_id=node_info["subscription_id"],
)
session.add(plugin_trigger)
session.flush() # Get the ID for caching
cache = Cache(
record_id=plugin_trigger.id,
node_id=node_info["node_id"],
provider_id=node_info["provider_id"],
trigger_name=node_info["trigger_name"],
subscription_id=node_info["subscription_id"],
)
redis_client.set(
f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:{node_info['node_id']}",
cache.model_dump_json(),
ex=60 * 60,
)
session.commit()
# Update existing records if subscription_id changed
for node_info in nodes_in_graph:
node_id = node_info["node_id"]
if node_id in nodes_id_in_db:
existing_record = nodes_id_in_db[node_id]
if (
existing_record.subscription_id != node_info["subscription_id"]
or existing_record.provider_id != node_info["provider_id"]
or existing_record.trigger_name != node_info["trigger_name"]
):
existing_record.subscription_id = node_info["subscription_id"]
existing_record.provider_id = node_info["provider_id"]
existing_record.trigger_name = node_info["trigger_name"]
session.add(existing_record)
# Update cache
cache = Cache(
record_id=existing_record.id,
node_id=node_id,
provider_id=node_info["provider_id"],
trigger_name=node_info["trigger_name"],
subscription_id=node_info["subscription_id"],
)
redis_client.set(
f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:{node_id}",
cache.model_dump_json(),
ex=60 * 60,
)
session.commit()
# delete the nodes not found in the graph
for node_id in nodes_id_in_db:
if node_id not in nodes_id_in_graph:
session.delete(nodes_id_in_db[node_id])
redis_client.delete(f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:{node_id}")
session.commit()
except Exception:
import logging
logger = logging.getLogger(__name__)
logger.exception("Failed to sync plugin trigger relationships for app %s", app.id)
raise
finally:
redis_client.delete(f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:apps:{app.id}:lock")

View File

@@ -280,6 +280,15 @@ const BasePanel: FC<BasePanelProps> = ({
})
}, [handleNodeDataUpdateWithSyncDraft, id])
const handleSubscriptionChange = useCallback((subscription_id: string) => {
handleNodeDataUpdateWithSyncDraft({
id,
data: {
subscription_id,
},
})
}, [handleNodeDataUpdateWithSyncDraft, id])
if(logParams.showSpecialResultPanel) {
return (
<div className={cn(
@@ -428,6 +437,7 @@ const BasePanel: FC<BasePanelProps> = ({
<NodeAuth
data={data}
onAuthorizationChange={handleAuthorizationItemClick}
onSubscriptionChange={handleSubscriptionChange}
/>
</div>
</PluginAuth>
@@ -451,6 +461,7 @@ const BasePanel: FC<BasePanelProps> = ({
<NodeAuth
data={data}
onAuthorizationChange={handleAuthorizationItemClick}
onSubscriptionChange={handleSubscriptionChange}
/>
</div>
)

View File

@@ -7,7 +7,6 @@ import { BlockEnum, type Node } from '@/app/components/workflow/types'
import { canFindTool } from '@/utils'
import { useStore } from '@/app/components/workflow/store'
import AuthenticationMenu from '@/app/components/workflow/nodes/trigger-plugin/components/authentication-menu'
import type { AuthSubscription } from '@/app/components/workflow/nodes/trigger-plugin/components/authentication-menu'
import {
useDeleteTriggerSubscription,
useInitiateTriggerOAuth,
@@ -20,9 +19,10 @@ import { openOAuthPopup } from '@/hooks/use-oauth'
type NodeAuthProps = {
data: Node['data']
onAuthorizationChange: (credential_id: string) => void
onSubscriptionChange?: (subscription_id: string) => void
}
const NodeAuth: FC<NodeAuthProps> = ({ data, onAuthorizationChange }) => {
const NodeAuth: FC<NodeAuthProps> = ({ data, onAuthorizationChange, onSubscriptionChange }) => {
const { t } = useTranslation()
const buildInTools = useStore(s => s.buildInTools)
const { notify } = useToastContext()
@@ -51,39 +51,8 @@ const NodeAuth: FC<NodeAuthProps> = ({ data, onAuthorizationChange }) => {
return buildInTools.find(item => canFindTool(item.id, data.provider_id))
}, [buildInTools, data.provider_id])
// Convert TriggerSubscription to AuthSubscription format
const authSubscription: AuthSubscription = useMemo(() => {
if (data.type !== BlockEnum.TriggerPlugin) {
return {
id: '',
name: '',
status: 'not_configured',
credentials: {},
}
}
const subscription = subscriptions[0] // Use first subscription if available
if (!subscription) {
return {
id: '',
name: '',
status: 'not_configured',
credentials: {},
}
}
const status = subscription.credential_type === 'unauthorized'
? 'not_configured'
: 'authorized'
return {
id: subscription.id,
name: subscription.name,
status,
credentials: subscription.credentials,
}
}, [data.type, subscriptions])
// Get selected subscription ID from node data
const selectedSubscriptionId = data.subscription_id
const handleConfigure = useCallback(async () => {
if (!provider) return
@@ -117,10 +86,35 @@ const NodeAuth: FC<NodeAuthProps> = ({ data, onAuthorizationChange }) => {
}
}, [provider, initiateTriggerOAuth, invalidateSubscriptions, notify])
const handleRemove = useCallback(() => {
if (authSubscription.id)
deleteSubscription.mutate(authSubscription.id)
}, [authSubscription.id, deleteSubscription])
const handleRemove = useCallback(async (subscriptionId: string) => {
if (!subscriptionId) return
try {
await deleteSubscription.mutateAsync(subscriptionId)
// Clear subscription_id from node data
if (onSubscriptionChange)
onSubscriptionChange('')
// Refresh subscriptions list
invalidateSubscriptions(provider)
notify({
type: 'success',
message: t('workflow.nodes.triggerPlugin.subscriptionRemoved'),
})
}
catch (error: any) {
notify({
type: 'error',
message: `Failed to remove subscription: ${error.message}`,
})
}
}, [deleteSubscription, invalidateSubscriptions, notify, onSubscriptionChange, provider, t])
const handleSubscriptionSelect = useCallback((subscriptionId: string) => {
if (onSubscriptionChange)
onSubscriptionChange(subscriptionId)
}, [onSubscriptionChange])
// Tool authentication
if (data.type === BlockEnum.Tool && currCollection?.allow_delete) {
@@ -140,7 +134,9 @@ const NodeAuth: FC<NodeAuthProps> = ({ data, onAuthorizationChange }) => {
if (data.type === BlockEnum.TriggerPlugin) {
return (
<AuthenticationMenu
subscription={authSubscription}
subscriptions={subscriptions}
selectedSubscriptionId={selectedSubscriptionId}
onSubscriptionSelect={handleSubscriptionSelect}
onConfigure={handleConfigure}
onRemove={handleRemove}
/>

View File

@@ -1,12 +1,13 @@
'use client'
import type { FC } from 'react'
import { memo, useCallback, useState } from 'react'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine } from '@remixicon/react'
import { RiAddLine, RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import Button from '@/app/components/base/button'
import Indicator from '@/app/components/header/indicator'
import cn from '@/utils/classnames'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
export type AuthenticationStatus = 'authorized' | 'not_configured' | 'error'
@@ -18,14 +19,18 @@ export type AuthSubscription = {
}
type AuthenticationMenuProps = {
subscription?: AuthSubscription
subscriptions: TriggerSubscription[]
selectedSubscriptionId?: string
onSubscriptionSelect: (subscriptionId: string) => void
onConfigure: () => void
onRemove: () => void
onRemove: (subscriptionId: string) => void
className?: string
}
const AuthenticationMenu: FC<AuthenticationMenuProps> = ({
subscription,
subscriptions,
selectedSubscriptionId,
onSubscriptionSelect,
onConfigure,
onRemove,
className,
@@ -33,32 +38,40 @@ const AuthenticationMenu: FC<AuthenticationMenuProps> = ({
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const selectedSubscription = useMemo(() => {
return subscriptions.find(sub => sub.id === selectedSubscriptionId)
}, [subscriptions, selectedSubscriptionId])
const getStatusConfig = useCallback(() => {
if (!subscription) {
if (!selectedSubscription) {
if (subscriptions.length > 0) {
return {
label: t('workflow.nodes.triggerPlugin.selectSubscription'),
color: 'yellow' as const,
}
}
return {
label: t('workflow.nodes.triggerPlugin.notConfigured'),
color: 'red' as const,
}
}
switch (subscription.status) {
case 'authorized':
return {
label: t('workflow.nodes.triggerPlugin.authorized'),
color: 'green' as const,
}
case 'error':
return {
label: t('workflow.nodes.triggerPlugin.error'),
color: 'red' as const,
}
default:
return {
label: t('workflow.nodes.triggerPlugin.notConfigured'),
color: 'red' as const,
}
// Check if subscription is authorized based on credential_type
const isAuthorized = selectedSubscription.credential_type !== 'unauthorized'
if (isAuthorized) {
return {
label: selectedSubscription.name || t('workflow.nodes.triggerPlugin.authorized'),
color: 'green' as const,
}
}
}, [subscription, t])
else {
return {
label: t('workflow.nodes.triggerPlugin.notAuthorized'),
color: 'red' as const,
}
}
}, [selectedSubscription, subscriptions.length, t])
const statusConfig = getStatusConfig()
@@ -67,11 +80,16 @@ const AuthenticationMenu: FC<AuthenticationMenuProps> = ({
setIsOpen(false)
}, [onConfigure])
const handleRemove = useCallback(() => {
onRemove()
const handleRemove = useCallback((subscriptionId: string) => {
onRemove(subscriptionId)
setIsOpen(false)
}, [onRemove])
const handleSelectSubscription = useCallback((subscriptionId: string) => {
onSubscriptionSelect(subscriptionId)
setIsOpen(false)
}, [onSubscriptionSelect])
return (
<div className={cn('relative', className)}>
<Button
@@ -107,31 +125,78 @@ const AuthenticationMenu: FC<AuthenticationMenuProps> = ({
{/* Dropdown Menu */}
<div className={cn(
'absolute right-0 z-30 mt-1',
'w-[136px] rounded-xl border-[0.5px] border-components-panel-border',
'w-[240px] rounded-xl border-[0.5px] border-components-panel-border',
'bg-components-panel-bg-blur shadow-lg backdrop-blur-sm',
)}>
<div className="py-1">
{/* Subscription list */}
{subscriptions.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-text-tertiary">
{t('workflow.nodes.triggerPlugin.availableSubscriptions')}
</div>
<div className="max-h-[200px] overflow-y-auto">
{subscriptions.map((subscription) => {
const isSelected = subscription.id === selectedSubscriptionId
const isAuthorized = subscription.credential_type !== 'unauthorized'
return (
<button
key={subscription.id}
className={cn(
'flex w-full items-center justify-between px-3 py-2 text-left text-sm',
'hover:bg-state-base-hover',
isSelected && 'bg-state-base-hover',
)}
onClick={() => handleSelectSubscription(subscription.id)}
>
<div className="flex items-center gap-2">
<Indicator
color={isAuthorized ? 'green' : 'red'}
/>
<span className={cn(
'text-text-secondary',
isSelected && 'font-medium text-text-primary',
)}>
{subscription.name}
</span>
</div>
{isSelected && (
<RiCheckLine className="h-4 w-4 text-text-accent" />
)}
</button>
)
})}
</div>
<div className="my-1 h-[0.5px] bg-divider-subtle" />
</>
)}
{/* Add new subscription */}
<button
className={cn(
'block w-full px-4 py-2 text-left text-sm',
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm',
'text-text-secondary hover:bg-state-base-hover',
'mx-1 rounded-lg',
)}
onClick={handleConfigure}
>
{t('workflow.nodes.triggerPlugin.configuration')}
<RiAddLine className="h-4 w-4" />
{t('workflow.nodes.triggerPlugin.addSubscription')}
</button>
{subscription && subscription.status === 'authorized' && (
<button
className={cn(
'block w-full px-4 py-2 text-left text-sm',
'text-text-destructive hover:bg-state-destructive-hover',
'mx-1 rounded-lg',
)}
onClick={handleRemove}
>
{t('workflow.nodes.triggerPlugin.remove')}
</button>
{/* Remove subscription */}
{selectedSubscription && (
<>
<div className="my-1 h-[0.5px] bg-divider-subtle" />
<button
className={cn(
'block w-full px-3 py-2 text-left text-sm',
'text-text-destructive hover:bg-state-destructive-hover',
)}
onClick={() => handleRemove(selectedSubscription.id)}
>
{t('workflow.nodes.triggerPlugin.removeSubscription')}
</button>
</>
)}
</div>
</div>

View File

@@ -97,6 +97,7 @@ export type CommonNodeType<T = {}> = {
retry_config?: WorkflowRetryConfig
default_value?: DefaultValueForm[]
credential_id?: string
subscription_id?: string
_dimmed?: boolean
} & T & Partial<Pick<ToolDefaultValue, 'provider_id' | 'provider_type' | 'provider_name' | 'tool_name'>>

View File

@@ -727,6 +727,12 @@ const translation = {
triggerPlugin: {
authorized: 'Authorized',
notConfigured: 'Not Configured',
notAuthorized: 'Not Authorized',
selectSubscription: 'Select Subscription',
availableSubscriptions: 'Available Subscriptions',
addSubscription: 'Add New Subscription',
removeSubscription: 'Remove Subscription',
subscriptionRemoved: 'Subscription removed successfully',
error: 'Error',
configuration: 'Configuration',
remove: 'Remove',