@@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
44import { eq } from 'drizzle-orm' ;
55
66import { db } from '../db/index.js' ;
7- import { agentRunDefinitions , agentRuns , contentItems , contentTypes , domains , workflowTransitions , workflows } from '../db/schema.js' ;
7+ import { agentRunDefinitions , agentRuns , contentItems , contentTypes , domains , reviewTasks , workflowTransitions , workflows } from '../db/schema.js' ;
88import { resolvers } from './resolvers.js' ;
99import { schema } from './schema.js' ;
1010
@@ -606,6 +606,282 @@ describe('GraphQL Tenant Isolation', () => {
606606 await db . delete ( contentItems ) . where ( eq ( contentItems . id , contentItemId ) ) ;
607607 }
608608 if ( transitionId ) {
609+ await db . delete ( reviewTasks ) . where ( eq ( reviewTasks . workflowTransitionId , transitionId ) ) ;
610+ await db . delete ( workflowTransitions ) . where ( eq ( workflowTransitions . id , transitionId ) ) ;
611+ }
612+ if ( workflowId ) {
613+ await db . delete ( workflows ) . where ( eq ( workflows . id , workflowId ) ) ;
614+ }
615+ if ( scopedTypeId ) {
616+ await db . delete ( contentTypes ) . where ( eq ( contentTypes . id , scopedTypeId ) ) ;
617+ }
618+ }
619+ } ) ;
620+
621+ it ( 'recovers failed auto-submit run via resume and subsequent approve' , async ( ) => {
622+ let scopedTypeId : number | null = null ;
623+ let workflowId : number | null = null ;
624+ let transitionId : number | null = null ;
625+ let contentItemId : number | null = null ;
626+ let runId : string | null = null ;
627+
628+ try {
629+ const [ scopedType ] = await db . insert ( contentTypes ) . values ( {
630+ domainId : 1 ,
631+ name : `GraphQL Retry Type ${ Date . now ( ) } ` ,
632+ slug : `graphql-retry-${ Date . now ( ) } ` ,
633+ schema : JSON . stringify ( {
634+ type : 'object' ,
635+ properties : { title : { type : 'string' } } ,
636+ required : [ 'title' ]
637+ } ) ,
638+ basePrice : 0
639+ } ) . returning ( ) ;
640+ scopedTypeId = scopedType . id ;
641+
642+ const [ item ] = await db . insert ( contentItems ) . values ( {
643+ domainId : 1 ,
644+ contentTypeId : scopedTypeId ,
645+ data : JSON . stringify ( { title : 'graphql-retry-item' } ) ,
646+ status : 'draft'
647+ } ) . returning ( ) ;
648+ contentItemId = item . id ;
649+
650+ const createRunResponse = await app . inject ( {
651+ method : 'POST' ,
652+ url : '/graphql' ,
653+ headers : {
654+ 'x-domain-id' : '1'
655+ } ,
656+ payload : {
657+ query : `
658+ mutation CreateRun($goal: String!, $metadata: JSON) {
659+ createAgentRun(goal: $goal, runType: "review_backlog_manager", requireApproval: true, metadata: $metadata) {
660+ id
661+ status
662+ }
663+ }
664+ ` ,
665+ variables : {
666+ goal : `graphql-retry-run-${ Date . now ( ) } ` ,
667+ metadata : {
668+ contentTypeId : scopedTypeId ,
669+ autoSubmitReview : true
670+ }
671+ }
672+ }
673+ } ) ;
674+ expect ( createRunResponse . statusCode ) . toBe ( 200 ) ;
675+ const createRunPayload = createRunResponse . json ( ) as {
676+ data ?: {
677+ createAgentRun ?: {
678+ id : string ;
679+ status : string ;
680+ } ;
681+ } ;
682+ } ;
683+ runId = createRunPayload . data ?. createAgentRun ?. id ?? null ;
684+ expect ( runId ) . toBeTruthy ( ) ;
685+ expect ( createRunPayload . data ?. createAgentRun ?. status ) . toBe ( 'waiting_approval' ) ;
686+
687+ const initialApproveResponse = await app . inject ( {
688+ method : 'POST' ,
689+ url : '/graphql' ,
690+ headers : {
691+ 'x-domain-id' : '1'
692+ } ,
693+ payload : {
694+ query : `
695+ mutation ControlRun($id: ID!, $action: String!) {
696+ controlAgentRun(id: $id, action: $action) {
697+ id
698+ status
699+ }
700+ }
701+ ` ,
702+ variables : {
703+ id : runId ,
704+ action : 'approve'
705+ }
706+ }
707+ } ) ;
708+ expect ( initialApproveResponse . statusCode ) . toBe ( 200 ) ;
709+ const initialApprovePayload = initialApproveResponse . json ( ) as {
710+ data ?: {
711+ controlAgentRun ?: {
712+ status : string ;
713+ } ;
714+ } ;
715+ } ;
716+ expect ( initialApprovePayload . data ?. controlAgentRun ?. status ) . toBe ( 'failed' ) ;
717+
718+ const [ workflow ] = await db . insert ( workflows ) . values ( {
719+ domainId : 1 ,
720+ name : `GraphQL Retry Workflow ${ Date . now ( ) } ` ,
721+ contentTypeId : scopedTypeId ,
722+ active : true
723+ } ) . returning ( ) ;
724+ workflowId = workflow . id ;
725+
726+ const [ transition ] = await db . insert ( workflowTransitions ) . values ( {
727+ workflowId : workflow . id ,
728+ fromState : 'draft' ,
729+ toState : 'pending_review' ,
730+ requiredRoles : [ ]
731+ } ) . returning ( ) ;
732+ transitionId = transition . id ;
733+
734+ const resumeRunResponse = await app . inject ( {
735+ method : 'POST' ,
736+ url : '/graphql' ,
737+ headers : {
738+ 'x-domain-id' : '1'
739+ } ,
740+ payload : {
741+ query : `
742+ mutation ControlRun($id: ID!, $action: String!) {
743+ controlAgentRun(id: $id, action: $action) {
744+ id
745+ status
746+ }
747+ }
748+ ` ,
749+ variables : {
750+ id : runId ,
751+ action : 'resume'
752+ }
753+ }
754+ } ) ;
755+ expect ( resumeRunResponse . statusCode ) . toBe ( 200 ) ;
756+ const resumePayload = resumeRunResponse . json ( ) as {
757+ data ?: {
758+ controlAgentRun ?: {
759+ status : string ;
760+ } ;
761+ } ;
762+ } ;
763+ expect ( resumePayload . data ?. controlAgentRun ?. status ) . toBe ( 'queued' ) ;
764+
765+ const finalApproveResponse = await app . inject ( {
766+ method : 'POST' ,
767+ url : '/graphql' ,
768+ headers : {
769+ 'x-domain-id' : '1'
770+ } ,
771+ payload : {
772+ query : `
773+ mutation ControlRun($id: ID!, $action: String!) {
774+ controlAgentRun(id: $id, action: $action) {
775+ id
776+ status
777+ completedAt
778+ }
779+ }
780+ ` ,
781+ variables : {
782+ id : runId ,
783+ action : 'approve'
784+ }
785+ }
786+ } ) ;
787+ expect ( finalApproveResponse . statusCode ) . toBe ( 200 ) ;
788+ const finalApprovePayload = finalApproveResponse . json ( ) as {
789+ data ?: {
790+ controlAgentRun ?: {
791+ status : string ;
792+ completedAt : string | null ;
793+ } ;
794+ } ;
795+ } ;
796+ expect ( finalApprovePayload . data ?. controlAgentRun ?. status ) . toBe ( 'succeeded' ) ;
797+ expect ( finalApprovePayload . data ?. controlAgentRun ?. completedAt ) . not . toBeNull ( ) ;
798+
799+ const runDetailsResponse = await app . inject ( {
800+ method : 'POST' ,
801+ url : '/graphql' ,
802+ headers : {
803+ 'x-domain-id' : '1'
804+ } ,
805+ payload : {
806+ query : `
807+ query RunDetails($id: ID!) {
808+ agentRun(id: $id) {
809+ id
810+ status
811+ steps {
812+ actionType
813+ status
814+ responseSnapshot
815+ }
816+ checkpoints {
817+ checkpointKey
818+ payload
819+ }
820+ }
821+ }
822+ ` ,
823+ variables : {
824+ id : runId
825+ }
826+ }
827+ } ) ;
828+ expect ( runDetailsResponse . statusCode ) . toBe ( 200 ) ;
829+ const runDetailsPayload = runDetailsResponse . json ( ) as {
830+ data ?: {
831+ agentRun ?: {
832+ status : string ;
833+ steps : Array < {
834+ actionType : string ;
835+ status : string ;
836+ responseSnapshot ?: {
837+ reviewTaskId ?: number ;
838+ workflowTransitionId ?: number ;
839+ } | null ;
840+ } > ;
841+ checkpoints : Array < {
842+ checkpointKey : string ;
843+ payload ?: {
844+ failedCount ?: number ;
845+ succeededCount ?: number ;
846+ settledStatus ?: string ;
847+ } ;
848+ } > ;
849+ } | null ;
850+ } ;
851+ } ;
852+
853+ const runDetails = runDetailsPayload . data ?. agentRun ;
854+ expect ( runDetails ?. status ) . toBe ( 'succeeded' ) ;
855+ const submitSteps = ( runDetails ?. steps ?? [ ] ) . filter ( ( step ) => step . actionType === 'submit_review' ) ;
856+ expect ( submitSteps ) . toHaveLength ( 1 ) ;
857+ expect ( submitSteps [ 0 ] . status ) . toBe ( 'succeeded' ) ;
858+ expect ( submitSteps [ 0 ] . responseSnapshot ?. workflowTransitionId ) . toBe ( transitionId ) ;
859+ expect ( typeof submitSteps [ 0 ] . responseSnapshot ?. reviewTaskId ) . toBe ( 'number' ) ;
860+
861+ const retryCheckpoint = ( runDetails ?. checkpoints ?? [ ] ) . find (
862+ ( checkpoint ) => checkpoint . checkpointKey === 'review_retry_scheduled'
863+ ) ;
864+ expect ( retryCheckpoint ?. payload ?. failedCount ) . toBe ( 1 ) ;
865+
866+ const completionCheckpoint = ( runDetails ?. checkpoints ?? [ ] ) . find (
867+ ( checkpoint ) => checkpoint . checkpointKey === 'review_execution_completed'
868+ ) ;
869+ expect ( completionCheckpoint ?. payload ?. succeededCount ) . toBe ( 1 ) ;
870+
871+ const settledCheckpoints = ( runDetails ?. checkpoints ?? [ ] ) . filter (
872+ ( checkpoint ) => checkpoint . checkpointKey === 'control_approve_settled'
873+ ) ;
874+ expect ( settledCheckpoints . some ( ( checkpoint ) => checkpoint . payload ?. settledStatus === 'failed' ) ) . toBe ( true ) ;
875+ expect ( settledCheckpoints . some ( ( checkpoint ) => checkpoint . payload ?. settledStatus === 'succeeded' ) ) . toBe ( true ) ;
876+ } finally {
877+ if ( runId ) {
878+ await db . delete ( agentRuns ) . where ( eq ( agentRuns . id , Number . parseInt ( runId , 10 ) ) ) ;
879+ }
880+ if ( contentItemId ) {
881+ await db . delete ( contentItems ) . where ( eq ( contentItems . id , contentItemId ) ) ;
882+ }
883+ if ( transitionId ) {
884+ await db . delete ( reviewTasks ) . where ( eq ( reviewTasks . workflowTransitionId , transitionId ) ) ;
609885 await db . delete ( workflowTransitions ) . where ( eq ( workflowTransitions . id , transitionId ) ) ;
610886 }
611887 if ( workflowId ) {
0 commit comments