running MVP2

This commit is contained in:
ValueOn AG 2025-08-23 14:40:36 +02:00
parent 1b59810e0d
commit 2a02bb1c01
12 changed files with 199 additions and 110 deletions

View file

@ -642,8 +642,13 @@ class HandlingTasks:
action.result = first_doc.documentData.get("result", "")
elif hasattr(first_doc, 'documentData') and isinstance(first_doc.documentData, str):
action.result = first_doc.documentData
action.execResultLabel = result.resultLabel or result_label
await self.createActionMessage(action, result, workflow, result.resultLabel or result_label, created_documents, task_step, task_index)
# Preserve the action's execResultLabel for document routing
# Action methods should NOT return resultLabel - this is managed by the action handler
if not action.execResultLabel:
logger.warning(f"Action {action.execMethod}.{action.execAction} has no execResultLabel set")
# Always use the action's execResultLabel for message creation to ensure proper document routing
message_result_label = action.execResultLabel
await self.createActionMessage(action, result, workflow, message_result_label, created_documents, task_step, task_index)
# Log action results
logger.info(f"✓ Action completed successfully")
@ -719,7 +724,7 @@ class HandlingTasks:
return ActionResult(
success=result.success,
documents=original_documents, # Preserve original documents field from method result
resultLabel=result.resultLabel or result_label,
resultLabel=action.execResultLabel, # Always use action's execResultLabel
error=result.error or ""
)
except Exception as e:
@ -728,7 +733,7 @@ class HandlingTasks:
return ActionResult(
success=False,
documents=[], # Empty documents for error case
resultLabel=result_label,
resultLabel=action.execResultLabel,
error=str(e)
)

View file

@ -10,7 +10,17 @@ import inspect
logger = logging.getLogger(__name__)
def action(func):
"""Decorator to mark a method as an available action"""
"""Decorator to mark a method as an available action
IMPORTANT: Action methods should NOT return resultLabel in their ActionResult.
The resultLabel is managed by the action handler using the action's execResultLabel
from the action plan. This ensures consistent document routing throughout the workflow.
Action methods should only return:
- success: bool
- documents: List[ActionDocument]
- error: str (if success=False)
"""
@wraps(func)
async def wrapper(self, parameters: Dict[str, Any], *args, **kwargs):
return await func(self, parameters, *args, **kwargs)

View file

@ -468,7 +468,8 @@ class ServiceCenter:
token = None
token_status = "unknown"
try:
token = self.interfaceApp.getToken(connection.authority.value)
# Use getConnectionToken to find token for this specific connection
token = self.interfaceApp.getConnectionToken(connection.id)
if token:
if hasattr(token, 'expiresAt') and token.expiresAt:
current_time = get_utc_timestamp()

View file

@ -740,9 +740,13 @@ class AppObjects:
"message": f"Error checking username availability: {str(e)}"
}
def saveToken(self, token: Token) -> None:
"""Save a token for the current user"""
def saveAccessToken(self, token: Token) -> None:
"""Save an access token for the current user (must NOT have connectionId)"""
try:
# Validate that this is NOT a connection token
if token.connectionId:
raise ValueError("Access tokens cannot have connectionId - use saveConnectionToken instead")
# Validate user context
if not self.currentUser or not self.currentUser.id:
raise ValueError("No valid user context available for token storage")
@ -758,7 +762,7 @@ class AppObjects:
# Convert to dict and ensure all fields are properly set
token_dict = token.dict()
# Ensure userId is set to current user (this might override the token's userId)
# Ensure userId is set to current user
token_dict["userId"] = self.currentUser.id
# Save to database
@ -767,19 +771,57 @@ class AppObjects:
# Clear cache to ensure fresh data
self._clearTableCache("tokens")
except Exception as e:
logger.error(f"Error saving token: {str(e)}")
logger.error(f"Error saving access token: {str(e)}")
raise
def getToken(self, authority: str, auto_refresh: bool = True) -> Optional[Token]:
"""Get the latest valid token for the current user and authority, optionally auto-refresh if expired"""
def saveConnectionToken(self, token: Token) -> None:
"""Save a connection token (must have connectionId)"""
try:
# Get tokens for this user and authority
# Validate that this IS a connection token
if not token.connectionId:
raise ValueError("Connection tokens must have connectionId - use saveAccessToken instead")
# Validate user context
if not self.currentUser or not self.currentUser.id:
raise ValueError("No valid user context available for token storage")
# Set the user ID for the connection token
token.userId = self.currentUser.id
# Ensure token has required fields
if not token.id:
token.id = str(uuid.uuid4())
if not token.createdAt:
token.createdAt = get_utc_timestamp()
# Convert to dict and ensure all fields are properly set
token_dict = token.dict()
# Ensure userId is set to current user
token_dict["userId"] = self.currentUser.id
# Save to database
self.db.recordCreate("tokens", token_dict)
# Clear cache to ensure fresh data
self._clearTableCache("tokens")
except Exception as e:
logger.error(f"Error saving connection token: {str(e)}")
raise
def getAccessToken(self, authority: str, auto_refresh: bool = True) -> Optional[Token]:
"""Get the latest valid access token for the current user and authority, optionally auto-refresh if expired"""
try:
# Validate that we're not looking for connection tokens
if not self.currentUser or not self.currentUser.id:
raise ValueError("No valid user context available for token retrieval")
# Get access tokens for this user and authority (must NOT have connectionId)
tokens = self.db.getRecordset("tokens", recordFilter={
"userId": self.currentUser.id,
"authority": authority
"authority": authority,
"connectionId": None # Ensure we only get access tokens
})
if not tokens:
@ -792,8 +834,6 @@ class AppObjects:
# Check if token is expired
if latest_token.expiresAt and latest_token.expiresAt < get_utc_timestamp():
if auto_refresh:
# Import TokenManager here to avoid circular imports
from modules.security.tokenManager import TokenManager
token_manager = TokenManager()
@ -802,45 +842,58 @@ class AppObjects:
refreshed_token = token_manager.refresh_token(latest_token)
if refreshed_token:
# Save the new token and delete the old one
self.saveToken(refreshed_token)
self.deleteToken(authority)
self.saveAccessToken(refreshed_token)
self.deleteAccessToken(authority)
return refreshed_token
else:
logger.warning(f"Failed to refresh expired token for {authority}")
logger.warning(f"Failed to refresh expired access token for {authority}")
return None
else:
logger.warning(f"Token for {authority} is expired (expiresAt: {latest_token.expiresAt})")
logger.warning(f"Access token for {authority} is expired (expiresAt: {latest_token.expiresAt})")
return None
return latest_token
except Exception as e:
logger.error(f"Error getting token: {str(e)}")
logger.error(f"Error getting access token: {str(e)}")
return None
def getTokenForConnection(self, connectionId: str, auto_refresh: bool = True) -> Optional[Token]:
"""Get the token for a specific connection, optionally auto-refresh if expired"""
def getConnectionToken(self, connectionId: str, auto_refresh: bool = True) -> Optional[Token]:
"""Get the connection token for a specific connectionId, optionally auto-refresh if expired"""
try:
logger.debug(f"Getting connection token for connectionId: {connectionId}")
# Validate connectionId
if not connectionId:
raise ValueError("connectionId is required for getConnectionToken")
# Get token for this specific connection
logger.debug(f"Querying tokens table with connectionId: {connectionId}")
# Query for specific connection
tokens = self.db.getRecordset("tokens", recordFilter={
"connectionId": connectionId
})
logger.debug(f"Raw tokens from database for connectionId {connectionId}: {tokens}")
logger.debug(f"Tokens count: {len(tokens) if tokens else 0}")
if not tokens:
logger.warning(f"No token found for connection: {connectionId}")
logger.warning(f"No connection token found for connectionId: {connectionId}")
return None
# Sort by expiration date and get the latest (most recent expiration)
logger.debug(f"Sorting tokens by expiresAt, current tokens: {tokens}")
tokens.sort(key=lambda x: x.get("expiresAt", 0), reverse=True)
latest_token = Token(**tokens[0])
logger.debug(f"Latest connection token: {latest_token}")
logger.debug(f"Token expiresAt: {latest_token.expiresAt}, type: {type(latest_token.expiresAt)}")
logger.debug(f"Current UTC timestamp: {get_utc_timestamp()}, type: {type(get_utc_timestamp())}")
# Check if token is expired
if latest_token.expiresAt and latest_token.expiresAt < get_utc_timestamp():
if auto_refresh:
# Import TokenManager here to avoid circular imports
from modules.security.tokenManager import TokenManager
token_manager = TokenManager()
@ -849,31 +902,36 @@ class AppObjects:
refreshed_token = token_manager.refresh_token(latest_token)
if refreshed_token:
# Save the new token and delete the old one
self.saveToken(refreshed_token)
self.deleteTokenByConnectionId(connectionId)
self.saveConnectionToken(refreshed_token)
self.deleteConnectionTokenByConnectionId(connectionId)
return refreshed_token
else:
logger.warning(f"Failed to refresh expired token for connection {connectionId}")
logger.warning(f"Failed to refresh expired connection token for connectionId {connectionId}")
return None
else:
logger.warning(f"Token for connection {connectionId} is expired (expiresAt: {latest_token.expiresAt})")
logger.warning(f"Connection token for connectionId {connectionId} is expired (expiresAt: {latest_token.expiresAt})")
return None
logger.debug(f"Returning valid connection token: {latest_token}")
return latest_token
except Exception as e:
logger.error(f"Error getting token for connection {connectionId}: {str(e)}")
logger.error(f"Error getting connection token for connectionId {connectionId}: {str(e)}")
return None
def deleteToken(self, authority: str) -> None:
"""Delete all tokens for the current user and authority"""
def deleteAccessToken(self, authority: str) -> None:
"""Delete all access tokens for the current user and authority"""
try:
# Get tokens to delete
# Validate user context
if not self.currentUser or not self.currentUser.id:
raise ValueError("No valid user context available for token deletion")
# Get access tokens to delete (must NOT have connectionId)
tokens = self.db.getRecordset("tokens", recordFilter={
"userId": self.currentUser.id,
"authority": authority
"authority": authority,
"connectionId": None # Ensure we only delete access tokens
})
# Delete each token
@ -884,13 +942,17 @@ class AppObjects:
self._clearTableCache("tokens")
except Exception as e:
logger.error(f"Error deleting token: {str(e)}")
logger.error(f"Error deleting access token: {str(e)}")
raise
def deleteTokenByConnectionId(self, connectionId: str) -> None:
"""Delete all tokens for a specific connection"""
def deleteConnectionTokenByConnectionId(self, connectionId: str) -> None:
"""Delete all connection tokens for a specific connectionId"""
try:
# Get tokens to delete
# Validate connectionId
if not connectionId:
raise ValueError("connectionId is required for deleteConnectionTokenByConnectionId")
# Get connection tokens to delete
tokens = self.db.getRecordset("tokens", recordFilter={
"connectionId": connectionId
})
@ -903,9 +965,15 @@ class AppObjects:
self._clearTableCache("tokens")
except Exception as e:
logger.error(f"Error deleting token for connection {connectionId}: {str(e)}")
logger.error(f"Error deleting connection token for connectionId {connectionId}: {str(e)}")
raise
# Backward compatibility method
def getTokenForConnection(self, connectionId: str, auto_refresh: bool = True) -> Optional[Token]:
"""Backward compatibility method - use getConnectionToken instead"""
logger.warning("getTokenForConnection is deprecated, use getConnectionToken instead")
return self.getConnectionToken(connectionId, auto_refresh)
def cleanupExpiredTokens(self) -> int:
"""Clean up expired tokens for all connections, returns count of cleaned tokens"""
try:

View file

@ -31,32 +31,41 @@ register_model_labels(
)
class ActionResult(BaseModel, ModelMixin):
"""Clean action result with documents as primary output"""
"""Clean action result with documents as primary output
IMPORTANT: Action methods should NOT set resultLabel in their return value.
The resultLabel is managed by the action handler using the action's execResultLabel
from the action plan. This ensures consistent document routing throughout the workflow.
"""
# Core result
success: bool = Field(description="Whether execution succeeded")
error: Optional[str] = Field(None, description="Error message if failed")
# Primary output - documents
documents: List[ActionDocument] = Field(default_factory=list, description="Document outputs")
resultLabel: Optional[str] = Field(None, description="Label for document routing")
resultLabel: Optional[str] = Field(None, description="Label for document routing (set by action handler, not by action methods)")
@classmethod
def success(cls, documents: List[ActionDocument] = None, resultLabel: str = None) -> 'ActionResult':
"""Create a successful action result"""
def success(cls, documents: List[ActionDocument] = None) -> 'ActionResult':
"""Create a successful action result
Note: Do not set resultLabel - this is managed by the action handler
"""
return cls(
success=True,
documents=documents or [],
resultLabel=resultLabel
documents=documents or []
)
@classmethod
def failure(cls, error: str, documents: List[ActionDocument] = None, resultLabel: str = None) -> 'ActionResult':
"""Create a failed action result"""
def failure(cls, error: str, documents: List[ActionDocument] = None) -> 'ActionResult':
"""Create a failed action result
Note: Do not set resultLabel - this is managed by the action handler
"""
return cls(
success=False,
documents=documents or [],
error=error,
resultLabel=resultLabel
error=error
)
# Register labels for ActionResult

View file

@ -102,18 +102,25 @@ class MethodOutlook(MethodBase):
Helper function to get Microsoft connection details.
"""
try:
logger.debug(f"Getting Microsoft connection for reference: {connectionReference}")
# Get the connection from the service
userConnection = self.service.getUserConnectionFromConnectionReference(connectionReference)
if not userConnection:
logger.error(f"Connection not found: {connectionReference}")
return None
logger.debug(f"Found connection: {userConnection.id}, status: {userConnection.status.value}, authority: {userConnection.authority.value}")
# Get the token for this specific connection
token = self.service.interfaceApp.getTokenForConnection(userConnection.id)
token = self.service.interfaceApp.getConnectionToken(userConnection.id)
if not token:
logger.error(f"Token not found for connection: {userConnection.id}")
logger.debug(f"Connection details: {userConnection}")
return None
logger.debug(f"Token retrieved for connection {userConnection.id}")
# Check if token is expired
if hasattr(token, 'expiresAt') and token.expiresAt:
current_time = get_utc_timestamp()
@ -126,8 +133,6 @@ class MethodOutlook(MethodBase):
logger.error(f"Connection is not active: {userConnection.id}, status: {userConnection.status.value}")
return None
return {
"id": userConnection.id,
"accessToken": token.tokenAccess,
@ -482,8 +487,7 @@ class MethodOutlook(MethodBase):
"timestamp": get_utc_timestamp()
},
"mimeType": "application/json"
}],
resultLabel="outlook_emails"
}]
)
except Exception as e:
@ -529,8 +533,10 @@ class MethodOutlook(MethodBase):
)
# Get Microsoft connection
logger.debug(f"Getting Microsoft connection for sendEmail action")
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
logger.error(f"Failed to get Microsoft connection for reference: {connectionReference}")
return ActionResult.failure(error="Failed to get Microsoft connection")
# Get the composed email document
@ -777,8 +783,7 @@ class MethodOutlook(MethodBase):
"timestamp": get_utc_timestamp()
},
"mimeType": "application/json"
}],
resultLabel="email_draft_created"
}]
)
else:
logger.error(f"Failed to create draft. Status: {response.status_code}, Response: {response.text}")
@ -815,8 +820,7 @@ class MethodOutlook(MethodBase):
"timestamp": get_utc_timestamp()
},
"mimeType": "application/json"
}],
resultLabel="outlook_email_draft"
}]
)
except Exception as e:
@ -998,8 +1002,7 @@ class MethodOutlook(MethodBase):
"timestamp": get_utc_timestamp()
},
"mimeType": "application/json"
}],
resultLabel="outlook_email_search"
}]
)
except Exception as e:
@ -1122,8 +1125,7 @@ class MethodOutlook(MethodBase):
"timestamp": get_utc_timestamp()
},
"mimeType": "application/json"
}],
resultLabel="outlook_drafts_list"
}]
)
except Exception as e:
@ -1233,8 +1235,7 @@ class MethodOutlook(MethodBase):
"timestamp": get_utc_timestamp()
},
"mimeType": "application/json"
}],
resultLabel="outlook_drafts_found"
}]
)
except Exception as e:
@ -1377,8 +1378,7 @@ class MethodOutlook(MethodBase):
"timestamp": get_utc_timestamp()
},
"mimeType": "application/json"
}],
resultLabel="outlook_drafts_folder_check"
}]
)
except Exception as e:
@ -1618,8 +1618,7 @@ class MethodOutlook(MethodBase):
"documentName": f"composed_email_{int(get_utc_timestamp())}.json",
"documentData": result_data,
"mimeType": "application/json"
}],
resultLabel="composed_email"
}]
)
except Exception as e:
@ -1662,8 +1661,7 @@ class MethodOutlook(MethodBase):
"status": "ready"
},
"mimeType": "application/json"
}],
resultLabel="permissions_ready"
}]
)
else:
return ActionResult(
@ -1680,8 +1678,7 @@ class MethodOutlook(MethodBase):
},
"mimeType": "application/json"
}],
error="Connection lacks necessary permissions for Outlook operations",
resultLabel="permissions_missing"
error="Connection lacks necessary permissions for Outlook operations"
)
except Exception as e:

View file

@ -44,7 +44,7 @@ class MethodSharepoint(MethodBase):
return None
# Get the token for this specific connection
token = self.service.interfaceApp.getTokenForConnection(userConnection.id)
token = self.service.interfaceApp.getConnectionToken(userConnection.id)
if not token:
logger.warning(f"No token found for connection {userConnection.id}")
return None
@ -283,8 +283,7 @@ class MethodSharepoint(MethodBase):
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="sharepoint_find_path"
]
)
except Exception as e:
@ -474,8 +473,7 @@ class MethodSharepoint(MethodBase):
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="sharepoint_documents"
]
)
except Exception as e:
logger.error(f"Error reading SharePoint documents: {str(e)}")
@ -652,8 +650,7 @@ class MethodSharepoint(MethodBase):
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="sharepoint_upload"
]
)
except Exception as e:
@ -861,8 +858,7 @@ class MethodSharepoint(MethodBase):
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="sharepoint_document_list"
]
)
except Exception as e:

View file

@ -562,8 +562,7 @@ class MethodWeb(MethodBase):
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="web_search_results"
]
)
except Exception as e:
@ -698,8 +697,7 @@ class MethodWeb(MethodBase):
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="web_crawl_results"
]
)
except Exception as e:
@ -803,8 +801,7 @@ class MethodWeb(MethodBase):
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="web_scrape_results"
]
)
except Exception as e:

View file

@ -223,9 +223,9 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse
createdAt=get_utc_timestamp()
)
# Save token
# Save access token (no connectionId)
appInterface = getInterface(user)
appInterface.saveToken(token)
appInterface.saveAccessToken(token)
# Return success page with token data
return HTMLResponse(
@ -347,7 +347,7 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse
expiresAt=create_expiration_timestamp(token_response.get("expires_in", 0)),
createdAt=get_utc_timestamp()
)
interface.saveToken(token)
interface.saveConnectionToken(token)
# Return success page with connection data
return HTMLResponse(
@ -491,7 +491,7 @@ async def refresh_token(
logger.debug(f"Found Google connection: {google_connection.id}, status={google_connection.status}")
# Get the token for this specific connection using the new method
current_token = appInterface.getTokenForConnection(google_connection.id, auto_refresh=False)
current_token = appInterface.getConnectionToken(google_connection.id, auto_refresh=False)
if not current_token:
raise HTTPException(
@ -507,9 +507,9 @@ async def refresh_token(
refreshed_token = token_manager.refresh_token(current_token)
if refreshed_token:
# Save the new token and delete the old one
appInterface.saveToken(refreshed_token)
appInterface.deleteTokenByConnectionId(google_connection.id)
# Save the new connection token and delete the old one
appInterface.saveConnectionToken(refreshed_token)
appInterface.deleteConnectionTokenByConnectionId(google_connection.id)
# Update the connection's expiration time
google_connection.expiresAt = float(refreshed_token.expiresAt)

View file

@ -103,8 +103,8 @@ async def login(
expiresAt=expires_at.timestamp()
)
# Save token
userInterface.saveToken(token)
# Save access token
userInterface.saveAccessToken(token)
# Create response data
response_data = {

View file

@ -173,9 +173,9 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse
createdAt=get_utc_timestamp()
)
# Save token
# Save access token (no connectionId)
appInterface = getInterface(user)
appInterface.saveToken(token)
appInterface.saveAccessToken(token)
# Create JWT token data
jwt_token_data = {
@ -198,8 +198,8 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse
createdAt=get_utc_timestamp()
)
# Save JWT token
appInterface.saveToken(jwt_token_obj)
# Save JWT access token
appInterface.saveAccessToken(jwt_token_obj)
# Convert token to dict and ensure proper timestamp handling
token_dict = jwt_token_obj.to_dict()
@ -328,7 +328,7 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse
)
interface.saveToken(token)
interface.saveConnectionToken(token)
# Return success page with connection data
@ -499,7 +499,7 @@ async def refresh_token(
# Get the token for this specific connection using the new method
# Enable auto-refresh to handle expired tokens gracefully
current_token = appInterface.getTokenForConnection(msft_connection.id, auto_refresh=True)
current_token = appInterface.getConnectionToken(msft_connection.id, auto_refresh=True)
if not current_token:
raise HTTPException(
@ -515,9 +515,9 @@ async def refresh_token(
refreshed_token = token_manager.refresh_token(current_token)
if refreshed_token:
# Save the new token and delete the old one
appInterface.saveToken(refreshed_token)
appInterface.deleteTokenByConnectionId(msft_connection.id)
# Save the new connection token and delete the old one
appInterface.saveConnectionToken(refreshed_token)
appInterface.deleteConnectionTokenByConnectionId(msft_connection.id)
# Update the connection's expiration time
msft_connection.expiresAt = float(refreshed_token.expiresAt)

View file

@ -1,6 +1,12 @@
TODO
- ui: Besseres Rendering der Tasks, Actions, Files (hierarchisch eingerückt) und der Log Entries ohne Rahmen
- ui: Beim Laden des Workflows die Logs und Messages synchron laden chronologisch
- documents: Sprechende Filenamen für user, ein Label für die interne Nutzung
- Chat: Pro Action und Task eine Message an den User in der UserLanguage
- check history --> tasks?
- model reference diagram for all models. who uses who? --> to see the basic building blocks