@@ -87,6 +87,8 @@ describe('RulesClient', () => {
8787
8888 ensureRuleExecutorTaskScheduledMock . mockResolvedValue ( { id : 'task-123' } ) ;
8989 getRuleExecutorTaskIdMock . mockReturnValue ( 'task:fallback' ) ;
90+
91+ taskManager . bulkRemove . mockResolvedValue ( { statuses : [ ] } ) ;
9092 } ) ;
9193
9294 afterAll ( ( ) => {
@@ -1367,7 +1369,44 @@ describe('RulesClient', () => {
13671369 ] ) ;
13681370 } ) ;
13691371
1370- it ( 'continues with deletion even if task removal fails' , async ( ) => {
1372+ it ( 'deletes the saved objects before removing the tasks' , async ( ) => {
1373+ const client = createClient ( ) ;
1374+
1375+ const callOrder : string [ ] = [ ] ;
1376+ rulesSavedObjectService . bulkDelete . mockImplementationOnce ( async ( ) => {
1377+ callOrder . push ( 'bulkDelete' ) ;
1378+ return [ { id : 'rule-1' , success : true } ] ;
1379+ } ) ;
1380+ taskManager . bulkRemove . mockImplementationOnce ( async ( ) => {
1381+ callOrder . push ( 'bulkRemove' ) ;
1382+ return { statuses : [ ] } ;
1383+ } ) ;
1384+
1385+ await client . bulkDeleteRules ( { ids : [ 'rule-1' ] } ) ;
1386+
1387+ expect ( callOrder ) . toEqual ( [ 'bulkDelete' , 'bulkRemove' ] ) ;
1388+ } ) ;
1389+
1390+ it ( 'only removes tasks for rules whose saved object was deleted' , async ( ) => {
1391+ const client = createClient ( ) ;
1392+
1393+ getRuleExecutorTaskIdMock . mockReturnValueOnce ( 'task:rule-1' ) ;
1394+
1395+ rulesSavedObjectService . bulkDelete . mockResolvedValueOnce ( [
1396+ { id : 'rule-1' , success : true } ,
1397+ {
1398+ id : 'rule-2' ,
1399+ success : false ,
1400+ error : { error : 'Not Found' , message : 'Rule not found' , statusCode : 404 } ,
1401+ } ,
1402+ ] ) ;
1403+
1404+ await client . bulkDeleteRules ( { ids : [ 'rule-1' , 'rule-2' ] } ) ;
1405+
1406+ expect ( taskManager . bulkRemove ) . toHaveBeenCalledWith ( [ 'task:rule-1' ] ) ;
1407+ } ) ;
1408+
1409+ it ( 'surfaces TASK_MANAGER_DRIFT errors when task removal fails' , async ( ) => {
13711410 const client = createClient ( ) ;
13721411
13731412 getRuleExecutorTaskIdMock
@@ -1383,7 +1422,70 @@ describe('RulesClient', () => {
13831422
13841423 const res = await client . bulkDeleteRules ( { ids : [ 'rule-1' , 'rule-2' ] } ) ;
13851424
1386- expect ( res ) . toEqual ( { affected_count : 2 , errors : [ ] } ) ;
1425+ // The saved objects are gone (affected), but the orphan tasks are flagged.
1426+ expect ( res . affected_count ) . toBe ( 2 ) ;
1427+ expect ( res . errors ) . toEqual ( [
1428+ { id : 'rule-1' , error : expect . objectContaining ( { code : 'TASK_MANAGER_DRIFT' } ) } ,
1429+ { id : 'rule-2' , error : expect . objectContaining ( { code : 'TASK_MANAGER_DRIFT' } ) } ,
1430+ ] ) ;
1431+ expect ( mockLogger . error ) . toHaveBeenCalledTimes ( 1 ) ;
1432+ } ) ;
1433+
1434+ it ( 'surfaces TASK_MANAGER_DRIFT for per-task failures in the bulkRemove statuses (no throw)' , async ( ) => {
1435+ const client = createClient ( ) ;
1436+
1437+ getRuleExecutorTaskIdMock
1438+ . mockReturnValueOnce ( 'task:rule-1' )
1439+ . mockReturnValueOnce ( 'task:rule-2' ) ;
1440+
1441+ rulesSavedObjectService . bulkDelete . mockResolvedValueOnce ( [
1442+ { id : 'rule-1' , success : true } ,
1443+ { id : 'rule-2' , success : true } ,
1444+ ] ) ;
1445+
1446+ taskManager . bulkRemove . mockResolvedValueOnce ( {
1447+ statuses : [
1448+ {
1449+ id : 'task:rule-1' ,
1450+ type : 'task' ,
1451+ success : false ,
1452+ error : { error : 'Internal' , message : 'boom' , statusCode : 500 } ,
1453+ } ,
1454+ { id : 'task:rule-2' , type : 'task' , success : true } ,
1455+ ] ,
1456+ } ) ;
1457+
1458+ const res = await client . bulkDeleteRules ( { ids : [ 'rule-1' , 'rule-2' ] } ) ;
1459+
1460+ expect ( res . affected_count ) . toBe ( 2 ) ;
1461+ expect ( res . errors ) . toEqual ( [
1462+ { id : 'rule-1' , error : expect . objectContaining ( { code : 'TASK_MANAGER_DRIFT' } ) } ,
1463+ ] ) ;
1464+ expect ( mockLogger . error ) . toHaveBeenCalledTimes ( 1 ) ;
1465+ } ) ;
1466+
1467+ it ( 'ignores missing tasks (404) in the bulkRemove statuses — the task is already gone' , async ( ) => {
1468+ const client = createClient ( ) ;
1469+
1470+ getRuleExecutorTaskIdMock . mockReturnValueOnce ( 'task:rule-1' ) ;
1471+
1472+ rulesSavedObjectService . bulkDelete . mockResolvedValueOnce ( [ { id : 'rule-1' , success : true } ] ) ;
1473+
1474+ taskManager . bulkRemove . mockResolvedValueOnce ( {
1475+ statuses : [
1476+ {
1477+ id : 'task:rule-1' ,
1478+ type : 'task' ,
1479+ success : false ,
1480+ error : { error : 'Not Found' , message : 'not found' , statusCode : 404 } ,
1481+ } ,
1482+ ] ,
1483+ } ) ;
1484+
1485+ const res = await client . bulkDeleteRules ( { ids : [ 'rule-1' ] } ) ;
1486+
1487+ expect ( res ) . toEqual ( { affected_count : 1 , errors : [ ] } ) ;
1488+ expect ( mockLogger . error ) . not . toHaveBeenCalled ( ) ;
13871489 } ) ;
13881490
13891491 it ( 'returns a zero-affected empty response when ids is an empty array' , async ( ) => {
@@ -1439,7 +1541,7 @@ describe('RulesClient', () => {
14391541 expect ( res ) . toEqual ( { affected_count : 1 , errors : [ ] } ) ;
14401542 } ) ;
14411543
1442- it ( 'logs a warning when task scheduling fails but still counts the rule as affected ' , async ( ) => {
1544+ it ( 'schedules the tasks before persisting enabled=true ' , async ( ) => {
14431545 const client = createClient ( ) ;
14441546
14451547 const disabledAttrs = createRuleSoAttributes ( {
@@ -1451,17 +1553,107 @@ describe('RulesClient', () => {
14511553 { id : 'rule-1' , attributes : disabledAttrs , version : 'v1' } ,
14521554 ] ) ;
14531555
1454- rulesSavedObjectService . bulkUpdate . mockResolvedValueOnce ( [ { id : 'rule-1' , success : true } ] ) ;
1556+ const callOrder : string [ ] = [ ] ;
1557+ taskManager . bulkSchedule . mockImplementationOnce ( async ( ) => {
1558+ callOrder . push ( 'bulkSchedule' ) ;
1559+ return [ ] ;
1560+ } ) ;
1561+ rulesSavedObjectService . bulkUpdate . mockImplementationOnce ( async ( ) => {
1562+ callOrder . push ( 'bulkUpdate' ) ;
1563+ return [ { id : 'rule-1' , success : true } ] ;
1564+ } ) ;
1565+
1566+ await client . bulkEnableRules ( { ids : [ 'rule-1' ] } ) ;
1567+
1568+ expect ( callOrder ) . toEqual ( [ 'bulkSchedule' , 'bulkUpdate' ] ) ;
1569+ } ) ;
1570+
1571+ it ( 'leaves rules disabled and surfaces TASK_MANAGER_DRIFT when scheduling fails' , async ( ) => {
1572+ const client = createClient ( ) ;
1573+
1574+ const disabledAttrs = createRuleSoAttributes ( {
1575+ metadata : { name : 'disabled-rule' } ,
1576+ enabled : false ,
1577+ } ) ;
1578+
1579+ rulesSavedObjectService . bulkGetByIds . mockResolvedValueOnce ( [
1580+ { id : 'rule-1' , attributes : disabledAttrs , version : 'v1' } ,
1581+ ] ) ;
14551582
1583+ getRuleExecutorTaskIdMock . mockReturnValue ( 'task:rule-1' ) ;
14561584 taskManager . bulkSchedule . mockRejectedValueOnce ( new Error ( 'Failed to grant UIAM API key' ) ) ;
14571585
14581586 const res = await client . bulkEnableRules ( { ids : [ 'rule-1' ] } ) ;
14591587
1460- expect ( mockLogger . warn ) . toHaveBeenCalledTimes ( 1 ) ;
1461- expect ( mockLogger . warn ) . toHaveBeenCalledWith (
1462- expect . stringContaining ( 'Failed to grant UIAM API key' )
1588+ // Scheduling failed first, so the saved object is never flipped to enabled.
1589+ expect ( rulesSavedObjectService . bulkUpdate ) . not . toHaveBeenCalled ( ) ;
1590+ expect ( taskManager . bulkRemove ) . toHaveBeenCalledWith ( [ 'task:rule-1' ] ) ;
1591+ expect ( res . affected_count ) . toBe ( 0 ) ;
1592+ expect ( res . errors ) . toEqual ( [
1593+ { id : 'rule-1' , error : expect . objectContaining ( { code : 'TASK_MANAGER_DRIFT' } ) } ,
1594+ ] ) ;
1595+ expect ( mockLogger . error ) . toHaveBeenCalledTimes ( 1 ) ;
1596+ expect ( ruleEventPublisher . emitRuleEnabled ) . not . toHaveBeenCalled ( ) ;
1597+ } ) ;
1598+
1599+ it ( 'rolls back partially scheduled tasks when bulkSchedule throws' , async ( ) => {
1600+ const client = createClient ( ) ;
1601+
1602+ const disabledAttrs = createRuleSoAttributes ( {
1603+ metadata : { name : 'disabled-rule' } ,
1604+ enabled : false ,
1605+ } ) ;
1606+
1607+ rulesSavedObjectService . bulkGetByIds . mockResolvedValueOnce ( [
1608+ { id : 'rule-1' , attributes : disabledAttrs , version : 'v1' } ,
1609+ { id : 'rule-2' , attributes : disabledAttrs , version : 'v1' } ,
1610+ ] ) ;
1611+
1612+ getRuleExecutorTaskIdMock . mockImplementation (
1613+ ( { ruleId } : { ruleId : string } ) => `task:${ ruleId } `
14631614 ) ;
1464- expect ( res ) . toEqual ( { affected_count : 1 , errors : [ ] } ) ;
1615+ taskManager . bulkSchedule . mockRejectedValueOnce ( new Error ( 'partial schedule failure' ) ) ;
1616+
1617+ const res = await client . bulkEnableRules ( { ids : [ 'rule-1' , 'rule-2' ] } ) ;
1618+
1619+ expect ( rulesSavedObjectService . bulkUpdate ) . not . toHaveBeenCalled ( ) ;
1620+ expect ( taskManager . bulkRemove ) . toHaveBeenCalledWith ( [ 'task:rule-1' , 'task:rule-2' ] ) ;
1621+ expect ( res . affected_count ) . toBe ( 0 ) ;
1622+ expect ( res . errors ) . toEqual ( [
1623+ { id : 'rule-1' , error : expect . objectContaining ( { code : 'TASK_MANAGER_DRIFT' } ) } ,
1624+ { id : 'rule-2' , error : expect . objectContaining ( { code : 'TASK_MANAGER_DRIFT' } ) } ,
1625+ ] ) ;
1626+ } ) ;
1627+
1628+ it ( 'cancels the just-scheduled task when the saved object update fails' , async ( ) => {
1629+ const client = createClient ( ) ;
1630+
1631+ const disabledAttrs = createRuleSoAttributes ( {
1632+ metadata : { name : 'disabled-rule' } ,
1633+ enabled : false ,
1634+ } ) ;
1635+
1636+ rulesSavedObjectService . bulkGetByIds . mockResolvedValueOnce ( [
1637+ { id : 'rule-1' , attributes : disabledAttrs , version : 'v1' } ,
1638+ ] ) ;
1639+
1640+ getRuleExecutorTaskIdMock . mockReturnValue ( 'task:rule-1' ) ;
1641+
1642+ rulesSavedObjectService . bulkUpdate . mockResolvedValueOnce ( [
1643+ {
1644+ id : 'rule-1' ,
1645+ success : false ,
1646+ error : { statusCode : 409 , error : 'Conflict' , message : 'Version conflict' } ,
1647+ } ,
1648+ ] ) ;
1649+
1650+ const res = await client . bulkEnableRules ( { ids : [ 'rule-1' ] } ) ;
1651+
1652+ expect ( taskManager . bulkRemove ) . toHaveBeenCalledWith ( [ 'task:rule-1' ] ) ;
1653+ expect ( res . affected_count ) . toBe ( 0 ) ;
1654+ expect ( res . errors ) . toEqual ( [
1655+ { id : 'rule-1' , error : { code : 'RULE_VERSION_CONFLICT' , message : 'Version conflict' } } ,
1656+ ] ) ;
14651657 } ) ;
14661658
14671659 it ( 'counts already-enabled rules as affected without updating them (idempotent)' , async ( ) => {
@@ -1620,7 +1812,7 @@ describe('RulesClient', () => {
16201812 ] ) ;
16211813 } ) ;
16221814
1623- it ( 'logs a warning and still counts the rule as affected when task removal fails' , async ( ) => {
1815+ it ( 'surfaces TASK_MANAGER_DRIFT errors when the task removal fails' , async ( ) => {
16241816 const client = createClient ( ) ;
16251817
16261818 const enabledAttrs = createRuleSoAttributes ( {
@@ -1639,9 +1831,12 @@ describe('RulesClient', () => {
16391831
16401832 const res = await client . bulkDisableRules ( { ids : [ 'rule-1' ] } ) ;
16411833
1642- expect ( mockLogger . warn ) . toHaveBeenCalledTimes ( 1 ) ;
1643- expect ( mockLogger . warn ) . toHaveBeenCalledWith ( expect . stringContaining ( 'task removal failed' ) ) ;
1644- expect ( res ) . toEqual ( { affected_count : 1 , errors : [ ] } ) ;
1834+ // The saved object is disabled (affected), but the task drift is flagged.
1835+ expect ( res . affected_count ) . toBe ( 1 ) ;
1836+ expect ( res . errors ) . toEqual ( [
1837+ { id : 'rule-1' , error : expect . objectContaining ( { code : 'TASK_MANAGER_DRIFT' } ) } ,
1838+ ] ) ;
1839+ expect ( mockLogger . error ) . toHaveBeenCalledTimes ( 1 ) ;
16451840 } ) ;
16461841
16471842 it ( 'returns a zero-affected empty response when ids is an empty array' , async ( ) => {
0 commit comments