2026-10-08 18:00:47 +02:00
""" Durable processing; financial effects are deliberately approval-only. """
import logging
import json
from psycopg2 . extras import Json
from . engine import evaluate
from . service import transaction , load_time_context , load_case_context , json_safe , is_incoming_mail
logger = logging . getLogger ( __name__ )
def process_pending_events ( limit = 20 , mode = ' shadow ' , customer_ids = None ) :
if mode not in ( ' shadow ' , ' active ' ) : raise ValueError ( ' Invalid workflow mode ' )
processed = 0
for _ in range ( min ( max ( int ( limit ) , 0 ) , 100 ) ) :
event = None
try :
with transaction ( ) as cursor :
cursor . execute ( ' SELECT * FROM bmc_workflow_runtime WHERE id=1 ' )
runtime = cursor . fetchone ( )
if not runtime or not runtime [ ' enabled ' ] : break
cursor . execute ( " SELECT * FROM bmc_workflow_events WHERE status= ' pending ' AND available_at<=now() ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1 " )
event = cursor . fetchone ( )
if not event : break
event = dict ( event )
if event [ ' trigger ' ] == ' mail.received ' and not is_incoming_mail ( event [ ' context ' ] ) :
cursor . execute ( " UPDATE bmc_workflow_events SET status= ' superseded ' ,error= ' Outgoing mail is not a mail.received trigger ' WHERE id= %s " , ( event [ ' id ' ] , ) )
continue
if len ( event . get ( ' chain ' ) or [ ] ) > = 20 :
cursor . execute ( " UPDATE bmc_workflow_events SET status= ' failed ' ,error= ' Loop limit exceeded ' WHERE id= %s " , ( event [ ' id ' ] , ) )
continue
cursor . execute ( " UPDATE bmc_workflow_events SET status= ' processing ' ,attempts=attempts+1 WHERE id= %s " , ( event [ ' id ' ] , ) )
if event [ ' entity_type ' ] in ( ' time ' , ' time_entry ' ) :
context = load_time_context ( event [ ' entity_id ' ] , cursor )
if event [ ' trigger ' ] not in ( ' time.created ' , ' time.updated ' ) :
event [ ' revision ' ] = context . get ( ' workflow_revision ' , 0 )
if int ( context . get ( ' workflow_revision ' , 0 ) ) != int ( event [ ' revision ' ] ) :
cursor . execute ( " UPDATE bmc_workflow_events SET status= ' superseded ' WHERE id= %s " , ( event [ ' id ' ] , ) )
continue
from app . workflows . classification import enrich_time_context
from app . core . config import settings
if getattr ( settings , ' WORKFLOW_AI_ENABLED ' , False ) :
from app . tags . backend . ai_jobs import enqueue_ai_job
enqueue_ai_job ( cursor , context )
context = enrich_time_context ( cursor , context )
elif event [ ' entity_type ' ] == ' case ' :
context = load_case_context ( event [ ' entity_id ' ] , cursor )
from . classification import enrich_case_context
context = enrich_case_context ( cursor , context )
elif event [ ' entity_type ' ] in ( ' task ' , ' reminder ' ) and event [ ' context ' ] . get ( ' sag_id ' ) :
context = load_case_context ( event [ ' context ' ] [ ' sag_id ' ] , cursor )
context . update ( event [ ' context ' ] )
context [ ' case_id ' ] = event [ ' context ' ] [ ' sag_id ' ]
context [ ' entity_type ' ] = event [ ' entity_type ' ]
else :
context = dict ( event [ ' context ' ] )
if event [ ' entity_type ' ] == ' customer ' : context [ ' customer_id ' ] = str ( event [ ' entity_id ' ] )
if event [ ' entity_type ' ] == ' mail ' : context [ ' description ' ] = context . get ( ' body_text ' ) or context . get ( ' body_html ' ) or ' '
if event [ ' entity_type ' ] in ( ' mail ' , ' email ' ) :
from . classification import enrich_mail_context
context = enrich_mail_context ( cursor , context )
if event [ ' context ' ] . get ( ' target_workflow_id ' ) :
context [ ' target_workflow_id ' ] = event [ ' context ' ] [ ' target_workflow_id ' ]
cursor . execute ( ' SELECT w.*,v.id AS version_id,v.definition FROM bmc_workflows w JOIN bmc_workflow_versions v ON v.id=w.active_version_id AND v.workflow_id=w.id WHERE w.enabled=TRUE ' )
result = evaluate ( cursor . fetchall ( ) , context , event [ ' trigger ' ] )
result [ ' input ' ] = context
effective_mode = mode
allowed = runtime [ ' customer_ids ' ] if customer_ids is None else customer_ids
internal_allowed = event [ ' entity_type ' ] in ( ' time ' , ' time_entry ' ) and context . get ( ' customer_id ' ) is None and not context . get ( ' customer_mapping_missing ' ) and runtime . get ( ' internal_enabled ' , False )
2026-10-08 21:09:25 +02:00
from . integration import customer_enabled
customer_allowed = context . get ( ' customer_id ' ) is not None and customer_enabled ( context [ ' customer_id ' ] , cursor , fallback_ids = allowed )
if mode == ' active ' and ( runtime [ ' mode ' ] != ' active ' or ( not internal_allowed and not customer_allowed ) ) :
2026-10-08 18:00:47 +02:00
effective_mode = ' shadow '
result [ ' mode ' ] = effective_mode
result [ ' billing ' ] = result . get ( ' decision ' , { } )
cursor . execute ( ' INSERT INTO bmc_workflow_executions(event_id,mode,result) VALUES( %s , %s , %s ) ON CONFLICT(event_id,mode) DO NOTHING ' , ( event [ ' id ' ] , effective_mode , Json ( json_safe ( result ) ) ) )
if effective_mode == ' active ' and event [ ' entity_type ' ] in ( ' time ' , ' time_entry ' ) :
cursor . execute ( ' SELECT * FROM tmodule_times WHERE id= %s FOR UPDATE ' , ( event [ ' entity_id ' ] , ) )
time = cursor . fetchone ( )
if int ( time . get ( ' workflow_revision ' , 0 ) ) != int ( event [ ' revision ' ] ) :
cursor . execute ( " UPDATE bmc_workflow_events SET status= ' superseded ' WHERE id= %s " , ( event [ ' id ' ] , ) )
continue
if any ( action [ ' type ' ] == ' suggest_tag ' for action in result [ ' actions ' ] ) :
result . update ( status = ' manual_review ' , method = None , reason = ' pending_tag_approval ' )
cursor . execute ( ' SELECT * FROM bmc_billing_decisions WHERE time_id= %s AND is_current FOR UPDATE ' , ( event [ ' entity_id ' ] , ) )
previous = cursor . fetchone ( )
cursor . execute ( ' SELECT time_id FROM bmc_workflow_settlements WHERE time_id= %s ' , ( event [ ' entity_id ' ] , ) )
settled = cursor . fetchone ( )
if not settled and time [ ' status ' ] not in ( ' approved ' , ' billed ' ) and not ( previous and ( previous [ ' manual_override ' ] or previous [ ' status ' ] == ' settled ' ) ) :
cursor . execute ( ' UPDATE bmc_billing_decisions SET is_current=FALSE WHERE time_id= %s AND is_current ' , ( event [ ' entity_id ' ] , ) )
cursor . execute ( ' INSERT INTO bmc_billing_decisions(time_id,revision,event_id,status,method,details) VALUES( %s , %s , %s , %s , %s , %s ) ' , ( event [ ' entity_id ' ] , event [ ' revision ' ] , event [ ' id ' ] , result [ ' status ' ] , result [ ' method ' ] , Json ( json_safe ( result ) ) ) )
if effective_mode == ' active ' :
chain = list ( event . get ( ' chain ' ) or [ ] )
matched_ids = list ( dict . fromkeys ( a [ ' source_workflow_id ' ] for a in result [ ' actions ' ] if a . get ( ' source_workflow_id ' ) ) )
repeated = [ action for action in result [ ' actions ' ] if action . get ( ' source_workflow_id ' ) in chain and action [ ' type ' ] in ( ' create_task ' , ' create_reminder ' , ' notify ' , ' start_workflow ' , ' set_field ' , ' assign_user ' , ' assign_group ' ) ]
if repeated :
raise ValueError ( ' Workflow action repeats in its causal chain ' )
cursor . execute ( " SELECT set_config( ' bmc.workflow_chain ' , %s ,TRUE) " , ( json . dumps ( chain + matched_ids ) , ) )
from . classification import apply_keyword_matches
if event [ ' entity_type ' ] in ( ' time ' , ' time_entry ' ) :
apply_keyword_matches ( cursor , context )
elif event [ ' entity_type ' ] in ( ' case ' , ' mail ' , ' email ' ) :
from . classification import apply_entity_keyword_matches
apply_entity_keyword_matches ( cursor , ' email ' if event [ ' entity_type ' ] == ' mail ' else event [ ' entity_type ' ] , event [ ' entity_id ' ] , context )
# Every local effect is deduplicated and committed with its event.
for index , action in enumerate ( result [ ' actions ' ] ) :
cursor . execute ( ' INSERT INTO bmc_workflow_effects(event_id,action_key,payload) VALUES( %s , %s , %s ) ON CONFLICT(action_key) DO NOTHING RETURNING id ' , ( event [ ' id ' ] , f " { event [ ' id ' ] } : { index } " , Json ( action ) ) )
inserted = cursor . fetchone ( )
if inserted and effective_mode == ' active ' and action [ ' type ' ] == ' set_billing ' and event [ ' entity_type ' ] not in ( ' time ' , ' time_entry ' ) :
cursor . execute ( " UPDATE bmc_workflow_effects SET status= ' skipped ' ,result= %s WHERE action_key= %s " , ( Json ( { ' reason ' : ' billing_requires_time_entry ' } ) , f " { event [ ' id ' ] } : { index } " ) )
if inserted and action [ ' type ' ] == ' send_to_review ' :
cursor . execute ( " UPDATE bmc_workflow_effects SET status= ' done ' WHERE action_key= %s " , ( f " { event [ ' id ' ] } : { index } " , ) )
if inserted and effective_mode == ' active ' and action [ ' type ' ] not in ( ' set_billing ' , ' send_to_review ' ) :
from . actions import execute_local_action
execute_local_action ( cursor , event , action )
cursor . execute ( " UPDATE bmc_workflow_effects SET status= ' done ' WHERE action_key= %s " , ( f " { event [ ' id ' ] } : { index } " , ) )
cursor . execute ( " UPDATE bmc_workflow_events SET status= ' done ' ,error=NULL WHERE id= %s " , ( event [ ' id ' ] , ) )
processed + = 1
except Exception as exc :
logger . exception ( ' Workflow processing failed ' )
if event :
with transaction ( ) as cursor :
cursor . execute ( " UPDATE bmc_workflow_events SET attempts=attempts+1,status=CASE WHEN attempts>=4 THEN ' failed ' ELSE ' pending ' END,available_at=now()+interval ' 30 seconds ' ,error= %s WHERE id= %s " , ( str ( exc ) [ : 1000 ] , event [ ' id ' ] ) )
else : raise
return processed