diff --git a/application/single_app/background_tasks.py b/application/single_app/background_tasks.py index 464255dd..a10edd90 100644 --- a/application/single_app/background_tasks.py +++ b/application/single_app/background_tasks.py @@ -47,7 +47,7 @@ update_group_workflow_runtime_fields, ) from functions_settings import get_settings, is_group_workflows_enabled_for_group, update_settings -from functions_workflow_runner import run_group_workflow, run_personal_workflow +from functions_workflow_runner import create_workflow_run_id, run_group_workflow, run_personal_workflow def _get_lock_holder_id(): @@ -514,18 +514,26 @@ def check_due_workflows_once(): pass started_at = datetime.now(timezone.utc).isoformat() + active_run_id = create_workflow_run_id() update_personal_workflow_runtime_fields( user_id, workflow_id, { 'status': 'running', + 'active_run_id': active_run_id, + 'cancellation_requested_at': None, + 'cancellation_requested_by': '', 'last_run_started_at': started_at, 'last_run_trigger_source': trigger_source, 'last_run_error': '', }, ) - result = run_personal_workflow(refreshed_workflow, trigger_source=trigger_source) + result = run_personal_workflow( + refreshed_workflow, + trigger_source=trigger_source, + run_id=active_run_id, + ) update_fields = dict(result.get('workflow_updates') or {}) update_fields['status'] = 'idle' update_fields['next_run_at'] = compute_next_run_at(refreshed_workflow, from_time=datetime.now(timezone.utc)) @@ -590,18 +598,26 @@ def check_due_workflows_once(): pass started_at = datetime.now(timezone.utc).isoformat() + active_run_id = create_workflow_run_id() update_group_workflow_runtime_fields( group_id, workflow_id, { 'status': 'running', + 'active_run_id': active_run_id, + 'cancellation_requested_at': None, + 'cancellation_requested_by': '', 'last_run_started_at': started_at, 'last_run_trigger_source': trigger_source, 'last_run_error': '', }, ) - result = run_group_workflow(refreshed_workflow, trigger_source=trigger_source) + result = run_group_workflow( + refreshed_workflow, + trigger_source=trigger_source, + run_id=active_run_id, + ) update_fields = dict(result.get('workflow_updates') or {}) update_fields['status'] = 'idle' update_fields['next_run_at'] = compute_next_run_at(refreshed_workflow, from_time=datetime.now(timezone.utc)) diff --git a/application/single_app/config.py b/application/single_app/config.py index a182549f..7cd617af 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -97,7 +97,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.061" +VERSION = "0.250.062" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_group_workflows.py b/application/single_app/functions_group_workflows.py index 6b24b3ae..8afd1ede 100644 --- a/application/single_app/functions_group_workflows.py +++ b/application/single_app/functions_group_workflows.py @@ -506,6 +506,9 @@ def save_group_workflow(group_id, workflow_data, actor_user_id, user_info=None): 'last_run_response_preview': (existing_workflow or {}).get('last_run_response_preview', ''), 'last_run_trigger_source': (existing_workflow or {}).get('last_run_trigger_source', ''), 'run_count': int((existing_workflow or {}).get('run_count') or 0), + 'active_run_id': (existing_workflow or {}).get('active_run_id', ''), + 'cancellation_requested_at': (existing_workflow or {}).get('cancellation_requested_at'), + 'cancellation_requested_by': (existing_workflow or {}).get('cancellation_requested_by', ''), } if trigger_type in {'interval', 'file_sync'} and is_enabled: diff --git a/application/single_app/functions_personal_workflows.py b/application/single_app/functions_personal_workflows.py index b946a351..b95f1a82 100644 --- a/application/single_app/functions_personal_workflows.py +++ b/application/single_app/functions_personal_workflows.py @@ -634,6 +634,9 @@ def save_personal_workflow(user_id, workflow_data, actor_user_id=None): 'last_run_response_preview': (existing_workflow or {}).get('last_run_response_preview', ''), 'last_run_trigger_source': (existing_workflow or {}).get('last_run_trigger_source', ''), 'run_count': int((existing_workflow or {}).get('run_count') or 0), + 'active_run_id': (existing_workflow or {}).get('active_run_id', ''), + 'cancellation_requested_at': (existing_workflow or {}).get('cancellation_requested_at'), + 'cancellation_requested_by': (existing_workflow or {}).get('cancellation_requested_by', ''), } if trigger_type in {'interval', 'file_sync'} and is_enabled: diff --git a/application/single_app/functions_workflow_activity.py b/application/single_app/functions_workflow_activity.py index fcd1c596..15852368 100644 --- a/application/single_app/functions_workflow_activity.py +++ b/application/single_app/functions_workflow_activity.py @@ -34,7 +34,9 @@ def _normalize_status(value): normalized_value = _normalize_text(value).lower() if normalized_value in {'running', 'pending', 'in_progress', 'in-progress'}: return 'running' - if normalized_value in {'failed', 'error', 'cancelled', 'canceled'}: + if normalized_value in {'cancelled', 'canceled'}: + return 'cancelled' + if normalized_value in {'failed', 'error'}: return 'failed' if normalized_value in {'completed', 'complete', 'succeeded', 'success', 'done'}: return 'completed' @@ -346,5 +348,5 @@ def build_workflow_activity_snapshot(run_record=None, workflow=None, conversatio 'run': _serialize_run(run_record), 'activities': activities, 'lane_count': max(1, len(lane_order) or 1), - 'live': _normalize_status((run_record or {}).get('status')) == 'running', + 'live': _normalize_text((run_record or {}).get('status')).lower() in {'running', 'cancelling'}, } \ No newline at end of file diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index aff2f384..add9737a 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -71,7 +71,13 @@ from functions_document_analysis import run_document_analysis from functions_file_sync import get_authorized_sync_source, queue_file_sync_source_run from functions_group import assert_group_role, get_group_model_endpoints, get_user_groups -from functions_group_workflows import save_group_workflow_run, save_group_workflow_run_item +from functions_group_workflows import ( + get_group_workflow, + get_group_workflow_run, + list_group_workflow_run_items, + save_group_workflow_run, + save_group_workflow_run_item, +) from functions_keyvault import SecretReturnType, keyvault_model_endpoint_get_helper from functions_message_artifacts import ( build_agent_citation_tool_label, @@ -83,7 +89,13 @@ ) from functions_model_endpoint_runtime import build_model_endpoint_sync_chat_client from functions_notifications import create_workflow_priority_notification -from functions_personal_workflows import save_personal_workflow_run, save_personal_workflow_run_item +from functions_personal_workflows import ( + get_personal_workflow, + get_personal_workflow_run, + list_personal_workflow_run_items, + save_personal_workflow_run, + save_personal_workflow_run_item, +) from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings from functions_search_service import resolve_document_context, search_documents from functions_search import normalize_search_id_list, normalize_search_scope, normalize_search_top_n @@ -109,6 +121,11 @@ DOCUMENT_ANALYSIS_ARTIFACT_PREVIEW_LINE_LENGTH = 220 TABULAR_DOCUMENT_EXTENSIONS = {'.csv', '.xls', '.xlsx', '.xlsm'} WORKFLOW_CONVERSATION_ACCESS_ERROR = 'Workflow conversation not found or access denied.' +WORKFLOW_RUN_CANCELLED_MESSAGE = 'Workflow cancellation was requested.' + + +class WorkflowRunCancelledError(BaseException): + """Raised when a persisted workflow cancellation request is observed.""" def get_workflow_kernel_settings(settings): @@ -142,6 +159,7 @@ def _is_authorized_workflow_conversation(conversation, workflow): def _save_workflow_run_record(workflow, run_record): + run_record = _preserve_workflow_run_cancellation_request(workflow, run_record) if _get_workflow_scope(workflow) == 'group': return save_group_workflow_run(_get_workflow_group_id(workflow), run_record) return save_personal_workflow_run(str((workflow or {}).get('user_id') or '').strip(), run_record) @@ -153,6 +171,108 @@ def _save_workflow_run_item_record(workflow, item_record): return save_personal_workflow_run_item(str((workflow or {}).get('user_id') or '').strip(), item_record) +def _get_workflow_run_record(workflow, run_id): + normalized_run_id = str(run_id or '').strip() + if not normalized_run_id: + return None + if _get_workflow_scope(workflow) == 'group': + return get_group_workflow_run(_get_workflow_group_id(workflow), normalized_run_id) + return get_personal_workflow_run(str((workflow or {}).get('user_id') or '').strip(), normalized_run_id) + + +def _get_current_workflow_runtime(workflow): + workflow_id = str((workflow or {}).get('id') or '').strip() + if not workflow_id: + return None + if _get_workflow_scope(workflow) == 'group': + return get_group_workflow(_get_workflow_group_id(workflow), workflow_id) + return get_personal_workflow(str((workflow or {}).get('user_id') or '').strip(), workflow_id) + + +def _list_workflow_run_items(workflow, run_id): + normalized_run_id = str(run_id or '').strip() + if not normalized_run_id: + return [] + if _get_workflow_scope(workflow) == 'group': + return list_group_workflow_run_items(normalized_run_id, limit=1000) + return list_personal_workflow_run_items(normalized_run_id, limit=1000) + + +def _has_workflow_run_cancellation_request(run_record): + run_record = run_record if isinstance(run_record, dict) else {} + return bool( + run_record.get('cancellation_requested_at') + or str(run_record.get('status') or '').strip().lower() in {'cancelling', 'cancelled', 'canceled'} + ) + + +def _is_workflow_run_cancellation_requested(workflow, run_id): + normalized_run_id = str(run_id or '').strip() + if not normalized_run_id: + return False + + run_record = _get_workflow_run_record(workflow, normalized_run_id) + if _has_workflow_run_cancellation_request(run_record): + return True + + runtime_workflow = _get_current_workflow_runtime(workflow) + if not isinstance(runtime_workflow, dict): + return False + + return ( + str(runtime_workflow.get('active_run_id') or '').strip() == normalized_run_id + and bool( + runtime_workflow.get('cancellation_requested_at') + or str(runtime_workflow.get('status') or '').strip().lower() == 'cancelling' + ) + ) + + +def _raise_if_workflow_run_cancelled(workflow, run_id): + if _is_workflow_run_cancellation_requested(workflow, run_id): + raise WorkflowRunCancelledError(WORKFLOW_RUN_CANCELLED_MESSAGE) + + +def _execute_cancelable_workflow_step(workflow, run_id, operation): + _raise_if_workflow_run_cancelled(workflow, run_id) + result = operation() + _raise_if_workflow_run_cancelled(workflow, run_id) + return result + + +def _preserve_workflow_run_cancellation_request(workflow, run_record): + run_record = dict(run_record or {}) + existing_run = _get_workflow_run_record(workflow, run_record.get('id')) + if not _has_workflow_run_cancellation_request(existing_run): + return run_record + + for field in ('cancellation_requested_at', 'cancellation_requested_by'): + if existing_run.get(field): + run_record[field] = existing_run.get(field) + + if str(existing_run.get('status') or '').strip().lower() in {'cancelled', 'canceled'}: + run_record['status'] = 'cancelled' + elif str(run_record.get('status') or '').strip().lower() != 'cancelled': + run_record['status'] = 'cancelling' + return run_record + + +def _mark_unfinished_workflow_run_items_cancelled(workflow, run_id): + completed_at = _utc_now_iso() + for item in _list_workflow_run_items(workflow, run_id): + status = str(item.get('status') or '').strip().lower() + if status in {'succeeded', 'failed', 'skipped', 'cancelled', 'canceled'}: + continue + cancelled_item = dict(item) + cancelled_item.update({ + 'status': 'cancelled', + 'completed_at': completed_at, + 'updated_at': completed_at, + 'error': cancelled_item.get('error') or WORKFLOW_RUN_CANCELLED_MESSAGE, + }) + _save_workflow_run_item_record(workflow, cancelled_item) + + def _utc_now(): return datetime.now(timezone.utc) @@ -161,6 +281,11 @@ def _utc_now_iso(): return _utc_now().isoformat() +def create_workflow_run_id(): + """Create a server-side identifier for a new workflow run.""" + return str(uuid.uuid4()) + + def _strip_markdown_code_fence(text): normalized_text = str(text or '').strip() if not normalized_text.startswith('```'): @@ -4117,17 +4242,22 @@ def _execute_workflow_file_sync(workflow, run_id, trigger_source): seen_document_ids = set() for source_config in config.get('sources') or []: + _raise_if_workflow_run_cancelled(workflow, run_id) source = get_authorized_sync_source( source_config.get('scope_type'), source_config.get('source_id'), user_id, scope_id=source_config.get('scope_id'), ) - run = queue_file_sync_source_run( - source, - triggered_by=user_id, - trigger='workflow', - run_inline=wait_mode == 'complete', + run = _execute_cancelable_workflow_step( + workflow, + run_id, + lambda: queue_file_sync_source_run( + source, + triggered_by=user_id, + trigger='workflow', + run_inline=wait_mode == 'complete', + ), ) run_summary = _summarize_file_sync_run(run) run_summary['workflow_run_id'] = run_id @@ -4269,6 +4399,11 @@ def _save_document_run_item(workflow, run_id, document_id, status, *, file_sync_ if not user_id or not run_id or not document_id: return None + cancellation_requested = _is_workflow_run_cancellation_requested(workflow, run_id) + if cancellation_requested: + status = 'cancelled' + error = error or WORKFLOW_RUN_CANCELLED_MESSAGE + now_iso = _utc_now_iso() file_sync_document = _file_sync_document_details(file_sync_result or {}, document_id) item = { @@ -4299,7 +4434,7 @@ def _save_document_run_item(workflow, run_id, document_id, status, *, file_sync_ item['created_at'] = now_iso if status == 'running': item['started_at'] = now_iso - if status in {'succeeded', 'failed', 'skipped'}: + if status in {'succeeded', 'failed', 'skipped', 'cancelled'}: item['completed_at'] = now_iso return _save_workflow_run_item_record(workflow, item) @@ -4685,6 +4820,7 @@ def _combine_per_document_analysis_results(document_results): def _execute_model_workflow(workflow, settings, run_id=None, thought_tracker=None, url_access_context=None): + _raise_if_workflow_run_cancelled(workflow, run_id) if thought_tracker and run_id: _add_workflow_activity_thought( thought_tracker, @@ -4701,12 +4837,16 @@ def _execute_model_workflow(workflow, settings, run_id=None, thought_tracker=Non client, deployment_name, provider = _resolve_model_workflow_client(workflow, settings) - completion = client.chat.completions.create( - model=deployment_name, - messages=_build_workflow_chat_messages( - workflow.get('task_prompt', ''), - url_access_context=url_access_context, - apply_generation_guidance=True, + completion = _execute_cancelable_workflow_step( + workflow, + run_id, + lambda: client.chat.completions.create( + model=deployment_name, + messages=_build_workflow_chat_messages( + workflow.get('task_prompt', ''), + url_access_context=url_access_context, + apply_generation_guidance=True, + ), ), ) reply = '' @@ -4744,6 +4884,7 @@ def _execute_document_analysis_workflow( action_config=None, url_access_context=None, ): + _raise_if_workflow_run_cancelled(workflow, run_id) analysis_config = action_config if isinstance(action_config, dict) else _get_document_action_config(workflow) if analysis_config.get('type') != DOCUMENT_ACTION_TYPE_ANALYZE: raise ValueError('Document analysis is not enabled for this workflow.') @@ -4788,6 +4929,7 @@ def _execute_document_analysis_workflow( per_document_results = [] for index, document_id in enumerate(analysis_document_ids, start=1): + _raise_if_workflow_run_cancelled(workflow, run_id) per_document_workflow = _build_per_document_workflow( workflow, analysis_config, @@ -4886,10 +5028,14 @@ def _execute_document_analysis_workflow( ) def invoke_prompt(prompt_text, stage='window_analysis', metadata=None): - result = asyncio.run(loaded_agent.invoke(_build_workflow_agent_messages( - prompt_text, - url_access_context=url_access_context, - ))) + result = _execute_cancelable_workflow_step( + workflow, + run_id, + lambda: asyncio.run(loaded_agent.invoke(_build_workflow_agent_messages( + prompt_text, + url_access_context=url_access_context, + ))), + ) _accumulate_token_usage(token_usage_aggregate, result) return str(result) @@ -4921,11 +5067,15 @@ def invoke_prompt(prompt_text, stage='window_analysis', metadata=None): activity_callback=activity_callback, max_documents=workflow_analysis_max_documents, ) - document_analysis_artifact_payload = _maybe_create_document_analysis_generated_artifacts( - analysis_result, - workflow.get('task_prompt', ''), - conversation_id=conversation_id, - primary_generated_outputs=list((tabular_action_payload or {}).get('generated_tabular_outputs') or []), + document_analysis_artifact_payload = _execute_cancelable_workflow_step( + workflow, + run_id, + lambda: _maybe_create_document_analysis_generated_artifacts( + analysis_result, + workflow.get('task_prompt', ''), + conversation_id=conversation_id, + primary_generated_outputs=list((tabular_action_payload or {}).get('generated_tabular_outputs') or []), + ), ) agent_citations = _build_agent_citations_from_invocations(user_id, conversation_id) if not agent_citations: @@ -4996,11 +5146,15 @@ def invoke_prompt(prompt_text, stage='window_analysis', metadata=None): client, deployment_name, provider = _resolve_model_workflow_client(workflow, settings) def invoke_model_prompt(prompt_text, stage='window_analysis', metadata=None): - completion = client.chat.completions.create( - model=deployment_name, - messages=_build_workflow_chat_messages( - prompt_text, - url_access_context=url_access_context, + completion = _execute_cancelable_workflow_step( + workflow, + run_id, + lambda: client.chat.completions.create( + model=deployment_name, + messages=_build_workflow_chat_messages( + prompt_text, + url_access_context=url_access_context, + ), ), ) _accumulate_token_usage(token_usage_aggregate, completion) @@ -5036,11 +5190,15 @@ def invoke_model_prompt(prompt_text, stage='window_analysis', metadata=None): activity_callback=activity_callback, max_documents=workflow_analysis_max_documents, ) - document_analysis_artifact_payload = _maybe_create_document_analysis_generated_artifacts( - analysis_result, - workflow.get('task_prompt', ''), - conversation_id=conversation_id, - primary_generated_outputs=list((tabular_action_payload or {}).get('generated_tabular_outputs') or []), + document_analysis_artifact_payload = _execute_cancelable_workflow_step( + workflow, + run_id, + lambda: _maybe_create_document_analysis_generated_artifacts( + analysis_result, + workflow.get('task_prompt', ''), + conversation_id=conversation_id, + primary_generated_outputs=list((tabular_action_payload or {}).get('generated_tabular_outputs') or []), + ), ) token_usage = _finalize_token_usage(token_usage_aggregate) debug_print( @@ -5079,6 +5237,7 @@ def _execute_document_comparison_workflow( action_config=None, url_access_context=None, ): + _raise_if_workflow_run_cancelled(workflow, run_id) comparison_config = action_config if isinstance(action_config, dict) else _get_document_action_config(workflow) if comparison_config.get('type') != DOCUMENT_ACTION_TYPE_COMPARISON: raise ValueError('Document comparison is not enabled for this workflow.') @@ -5158,10 +5317,14 @@ def _execute_document_comparison_workflow( ) def invoke_prompt(prompt_text, stage='window_analysis', metadata=None): - result = asyncio.run(loaded_agent.invoke(_build_workflow_agent_messages( - prompt_text, - url_access_context=url_access_context, - ))) + result = _execute_cancelable_workflow_step( + workflow, + run_id, + lambda: asyncio.run(loaded_agent.invoke(_build_workflow_agent_messages( + prompt_text, + url_access_context=url_access_context, + ))), + ) _accumulate_token_usage(token_usage_aggregate, result) return str(result) @@ -5186,10 +5349,14 @@ def invoke_prompt(prompt_text, stage='window_analysis', metadata=None): activity_callback=activity_callback, conversation_id=conversation_id, ) - comparison_artifact_payload = _maybe_create_comparison_generated_artifacts( - comparison_result, - workflow.get('task_prompt', ''), - conversation_id=conversation_id, + comparison_artifact_payload = _execute_cancelable_workflow_step( + workflow, + run_id, + lambda: _maybe_create_comparison_generated_artifacts( + comparison_result, + workflow.get('task_prompt', ''), + conversation_id=conversation_id, + ), ) agent_citations = _build_agent_citations_from_invocations(user_id, conversation_id) if not agent_citations: @@ -5260,11 +5427,15 @@ def invoke_prompt(prompt_text, stage='window_analysis', metadata=None): client, deployment_name, provider = _resolve_model_workflow_client(workflow, settings) def invoke_model_prompt(prompt_text, stage='window_analysis', metadata=None): - completion = client.chat.completions.create( - model=deployment_name, - messages=_build_workflow_chat_messages( - prompt_text, - url_access_context=url_access_context, + completion = _execute_cancelable_workflow_step( + workflow, + run_id, + lambda: client.chat.completions.create( + model=deployment_name, + messages=_build_workflow_chat_messages( + prompt_text, + url_access_context=url_access_context, + ), ), ) _accumulate_token_usage(token_usage_aggregate, completion) @@ -5293,10 +5464,14 @@ def invoke_model_prompt(prompt_text, stage='window_analysis', metadata=None): activity_callback=activity_callback, conversation_id=conversation_id, ) - comparison_artifact_payload = _maybe_create_comparison_generated_artifacts( - comparison_result, - workflow.get('task_prompt', ''), - conversation_id=conversation_id, + comparison_artifact_payload = _execute_cancelable_workflow_step( + workflow, + run_id, + lambda: _maybe_create_comparison_generated_artifacts( + comparison_result, + workflow.get('task_prompt', ''), + conversation_id=conversation_id, + ), ) token_usage = _finalize_token_usage(token_usage_aggregate) debug_print( @@ -5334,6 +5509,7 @@ def _execute_document_action_workflow( external_activity_callback=None, url_access_context=None, ): + _raise_if_workflow_run_cancelled(workflow, run_id) action_config = _get_document_action_config(workflow) action_config = _resolve_recent_document_action_targets(workflow, action_config, settings) workflow = _apply_runtime_document_action_config(workflow, action_config) @@ -5393,10 +5569,12 @@ def _execute_document_action_workflow( f"processed_windows={(result.get('analysis_coverage') or {}).get('processed_windows', 0)} | " f"failed_windows={(result.get('analysis_coverage') or {}).get('failed_windows', 0)}" ) + _raise_if_workflow_run_cancelled(workflow, run_id) return result def _execute_agent_workflow(workflow, settings, conversation_id='', run_id=None, thought_tracker=None, url_access_context=None): + _raise_if_workflow_run_cancelled(workflow, run_id) user_id = str(workflow.get('user_id') or '').strip() selected_agent = workflow.get('selected_agent') if isinstance(workflow.get('selected_agent'), dict) else {} if not selected_agent: @@ -5473,11 +5651,15 @@ def _execute_agent_workflow(workflow, settings, conversation_id='', run_id=None, if loaded_agent is None: loaded_agent = next(iter(agent_objs.values())) - result = asyncio.run(loaded_agent.invoke(_build_workflow_agent_messages( - workflow.get('task_prompt', ''), - url_access_context=url_access_context, - apply_generation_guidance=True, - ))) + result = _execute_cancelable_workflow_step( + workflow, + run_id, + lambda: asyncio.run(loaded_agent.invoke(_build_workflow_agent_messages( + workflow.get('task_prompt', ''), + url_access_context=url_access_context, + apply_generation_guidance=True, + ))), + ) reply = str(result) agent_citations = _build_agent_citations_from_invocations(user_id, conversation_id) alert_targets = _collect_agent_alert_targets(user_id, conversation_id) @@ -5549,14 +5731,98 @@ def _execute_agent_workflow(workflow, settings, conversation_id='', run_id=None, g.authorized_chat_context = previous_authorized_chat_context -def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, actor_user_id=None): +def _finalize_cancelled_workflow_run( + workflow, + run_record, + run_id, + started_at, + trigger_source, + execution_workflow, + thought_tracker=None, + file_sync_result=None, +): + """Persist the terminal cancellation state for a workflow run.""" + workflow = workflow if isinstance(workflow, dict) else {} + run_record = dict(run_record or {}) + user_id = str(workflow.get('user_id') or '').strip() + group_id = _get_workflow_group_id(workflow) + workspace_type = _get_workflow_scope(workflow) + workflow_id = str(workflow.get('id') or '').strip() + completed_at = _utc_now_iso() + runtime_workflow = _get_current_workflow_runtime(workflow) or {} + run_record.update({ + 'status': 'cancelled', + 'success': False, + 'completed_at': completed_at, + 'cancellation_requested_at': ( + run_record.get('cancellation_requested_at') + or runtime_workflow.get('cancellation_requested_at') + or completed_at + ), + 'cancellation_requested_by': ( + run_record.get('cancellation_requested_by') + or runtime_workflow.get('cancellation_requested_by') + or '' + ), + 'file_sync': file_sync_result or {}, + 'response_preview': '', + 'error': '', + }) + _mark_unfinished_workflow_run_items_cancelled(workflow, run_id) + if thought_tracker: + _add_workflow_activity_thought( + thought_tracker, + execution_workflow, + run_id, + step_type='workflow', + content='Workflow run cancelled', + detail=WORKFLOW_RUN_CANCELLED_MESSAGE, + activity_key=f'run:{run_id}', + kind='workflow_run', + title='Workflow run', + status='cancelled', + ) + _save_workflow_run_record(workflow, run_record) + log_workflow_run( + user_id=user_id, + workflow_id=workflow_id, + workflow_name=workflow.get('name', ''), + status='cancelled', + trigger_source=trigger_source, + run_id=run_id, + conversation_id=run_record.get('conversation_id'), + runner_type=workflow.get('runner_type'), + workspace_type=workspace_type, + group_id=group_id or None, + ) + return { + 'success': True, + 'run': run_record, + 'notification': None, + 'workflow_updates': { + 'last_run_started_at': started_at, + 'last_run_at': completed_at, + 'last_run_status': 'cancelled', + 'last_run_error': '', + 'last_run_response_preview': '', + 'last_run_trigger_source': trigger_source, + 'run_count': int(workflow.get('run_count') or 0) + 1, + 'conversation_id': run_record.get('conversation_id'), + 'active_run_id': '', + 'cancellation_requested_at': None, + 'cancellation_requested_by': '', + }, + } + + +def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, actor_user_id=None, run_id=None): """Execute a workflow and persist a run record.""" workflow = workflow if isinstance(workflow, dict) else {} user_id = str(workflow.get('user_id') or '').strip() group_id = _get_workflow_group_id(workflow) workspace_type = _get_workflow_scope(workflow) workflow_id = str(workflow.get('id') or '').strip() - run_id = str(uuid.uuid4()) + run_id = str(run_id or create_workflow_run_id()) started_at = _utc_now_iso() settings = get_settings() @@ -5578,6 +5844,8 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac 'conversation_id': workflow.get('conversation_id'), 'response_preview': '', 'error': '', + 'cancellation_requested_at': None, + 'cancellation_requested_by': '', } _save_workflow_run_record(workflow, run_record) @@ -5586,12 +5854,18 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac execution_workflow = workflow file_sync_result = None try: - file_sync_result = _execute_workflow_file_sync(workflow, run_id, trigger_source) + _raise_if_workflow_run_cancelled(workflow, run_id) + file_sync_result = _execute_cancelable_workflow_step( + workflow, + run_id, + lambda: _execute_workflow_file_sync(workflow, run_id, trigger_source), + ) if file_sync_result and file_sync_result.get('enabled'): run_record['file_sync'] = file_sync_result _save_workflow_run_record(workflow, run_record) if not file_sync_result.get('should_continue', True): + _raise_if_workflow_run_cancelled(workflow, run_id) completed_at = _utc_now_iso() response_preview = 'No new or changed files were detected by File Sync.' run_record.update({ @@ -5602,6 +5876,7 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac 'error': '', }) _save_workflow_run_record(workflow, run_record) + _raise_if_workflow_run_cancelled(workflow, run_id) log_workflow_run( user_id=user_id, workflow_id=workflow_id, @@ -5614,6 +5889,7 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac workspace_type=workspace_type, group_id=group_id or None, ) + _raise_if_workflow_run_cancelled(workflow, run_id) return { 'success': True, 'run': run_record, @@ -5627,14 +5903,20 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac 'last_run_trigger_source': trigger_source, 'run_count': int(workflow.get('run_count') or 0) + 1, 'conversation_id': run_record.get('conversation_id'), + 'active_run_id': '', + 'cancellation_requested_at': None, + 'cancellation_requested_by': '', }, } execution_workflow = _apply_file_sync_context_to_workflow(workflow, file_sync_result) + _raise_if_workflow_run_cancelled(workflow, run_id) conversation = _ensure_workflow_conversation(execution_workflow) run_record['conversation_id'] = conversation.get('id') + _raise_if_workflow_run_cancelled(workflow, run_id) user_message_doc = _create_user_message(conversation.get('id'), execution_workflow, trigger_source, run_id) + _raise_if_workflow_run_cancelled(workflow, run_id) assistant_message_id, thought_tracker = _initialize_workflow_assistant_tracking( conversation.get('id'), user_id, @@ -5657,28 +5939,37 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac status='running', ) - url_access_context = _prepare_workflow_url_access_context( + url_access_context = _execute_cancelable_workflow_step( execution_workflow, - settings, - conversation.get('id'), run_id, - thought_tracker=thought_tracker, - user_roles=user_roles, + lambda: _prepare_workflow_url_access_context( + execution_workflow, + settings, + conversation.get('id'), + run_id, + thought_tracker=thought_tracker, + user_roles=user_roles, + ), ) document_action = _get_document_action_config(execution_workflow) workflow_search_context = None if document_action.get('type') == DOCUMENT_ACTION_TYPE_SEARCH: - workflow_search_context = _prepare_workflow_search_context( + workflow_search_context = _execute_cancelable_workflow_step( execution_workflow, - document_action, - settings, - thought_tracker=thought_tracker, - run_id=run_id, + run_id, + lambda: _prepare_workflow_search_context( + execution_workflow, + document_action, + settings, + thought_tracker=thought_tracker, + run_id=run_id, + ), ) execution_workflow = workflow_search_context.get('workflow') or execution_workflow document_action = _get_document_action_config(execution_workflow) if document_action.get('type') in {DOCUMENT_ACTION_TYPE_ANALYZE, DOCUMENT_ACTION_TYPE_COMPARISON}: + _raise_if_workflow_run_cancelled(execution_workflow, run_id) document_action = _resolve_recent_document_action_targets(execution_workflow, document_action, settings) execution_workflow = _apply_runtime_document_action_config(execution_workflow, document_action) run_item_callback = _build_run_item_activity_callback( @@ -5692,23 +5983,31 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac document_action, file_sync_result=file_sync_result or {}, ) - execution_result = _execute_document_action_workflow( + execution_result = _execute_cancelable_workflow_step( execution_workflow, - settings, - conversation_id=conversation.get('id'), - run_id=run_id, - thought_tracker=thought_tracker, - external_activity_callback=run_item_callback, - url_access_context=url_access_context, + run_id, + lambda: _execute_document_action_workflow( + execution_workflow, + settings, + conversation_id=conversation.get('id'), + run_id=run_id, + thought_tracker=thought_tracker, + external_activity_callback=run_item_callback, + url_access_context=url_access_context, + ), ) elif execution_workflow.get('runner_type') == 'agent': - execution_result = _execute_agent_workflow( + execution_result = _execute_cancelable_workflow_step( execution_workflow, - settings, - conversation_id=conversation.get('id'), - run_id=run_id, - thought_tracker=thought_tracker, - url_access_context=url_access_context, + run_id, + lambda: _execute_agent_workflow( + execution_workflow, + settings, + conversation_id=conversation.get('id'), + run_id=run_id, + thought_tracker=thought_tracker, + url_access_context=url_access_context, + ), ) if workflow_search_context: execution_result.update({ @@ -5721,12 +6020,16 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac }, }) else: - execution_result = _execute_model_workflow( + execution_result = _execute_cancelable_workflow_step( execution_workflow, - settings, - run_id=run_id, - thought_tracker=thought_tracker, - url_access_context=url_access_context, + run_id, + lambda: _execute_model_workflow( + execution_workflow, + settings, + run_id=run_id, + thought_tracker=thought_tracker, + url_access_context=url_access_context, + ), ) if workflow_search_context: execution_result.update({ @@ -5738,8 +6041,10 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac 'document_count': workflow_search_context.get('document_count', 0), }, }) + _raise_if_workflow_run_cancelled(execution_workflow, run_id) execution_result = _attach_workflow_url_access_result(execution_result, url_access_context) + _raise_if_workflow_run_cancelled(execution_workflow, run_id) assistant_doc = _create_assistant_message( conversation, execution_workflow, @@ -5749,11 +6054,13 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac user_message_doc, assistant_message_id=assistant_message_id, ) + _raise_if_workflow_run_cancelled(execution_workflow, run_id) _mirror_workflow_visualizations_to_created_conversations( execution_workflow, assistant_doc, execution_result, ) + _raise_if_workflow_run_cancelled(execution_workflow, run_id) _add_workflow_activity_thought( thought_tracker, @@ -5767,6 +6074,7 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac title='Workflow run', status='completed', ) + _raise_if_workflow_run_cancelled(execution_workflow, run_id) completed_at = _utc_now_iso() run_record.update({ @@ -5786,7 +6094,9 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac 'response_preview': _build_response_preview(execution_result.get('reply')), 'error': '', }) + _raise_if_workflow_run_cancelled(execution_workflow, run_id) _save_workflow_run_record(workflow, run_record) + _raise_if_workflow_run_cancelled(execution_workflow, run_id) log_workflow_run( user_id=user_id, workflow_id=workflow_id, @@ -5799,11 +6109,15 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac workspace_type=workspace_type, group_id=group_id or None, ) - alert_notification = _create_workflow_priority_alert( + alert_notification = _execute_cancelable_workflow_step( execution_workflow, - run_record, - conversation, - execution_result=execution_result, + run_id, + lambda: _create_workflow_priority_alert( + execution_workflow, + run_record, + conversation, + execution_result=execution_result, + ), ) return { @@ -5819,9 +6133,34 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac 'last_run_response_preview': run_record.get('response_preview', ''), 'last_run_trigger_source': trigger_source, 'run_count': int(workflow.get('run_count') or 0) + 1, + 'active_run_id': '', + 'cancellation_requested_at': None, + 'cancellation_requested_by': '', }, } + except WorkflowRunCancelledError: + return _finalize_cancelled_workflow_run( + workflow, + run_record, + run_id, + started_at, + trigger_source, + execution_workflow, + thought_tracker=thought_tracker, + file_sync_result=file_sync_result, + ) except Exception as exc: + if _is_workflow_run_cancellation_requested(workflow, run_id): + return _finalize_cancelled_workflow_run( + workflow, + run_record, + run_id, + started_at, + trigger_source, + execution_workflow, + thought_tracker=thought_tracker, + file_sync_result=file_sync_result, + ) if thought_tracker: _add_workflow_activity_thought( thought_tracker, @@ -5845,6 +6184,17 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac 'response_preview': '', }) _save_workflow_run_record(workflow, run_record) + if _is_workflow_run_cancellation_requested(workflow, run_id): + return _finalize_cancelled_workflow_run( + workflow, + run_record, + run_id, + started_at, + trigger_source, + execution_workflow, + thought_tracker=thought_tracker, + file_sync_result=file_sync_result, + ) log_workflow_run( user_id=user_id, workflow_id=workflow_id, @@ -5887,11 +6237,14 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac 'last_run_trigger_source': trigger_source, 'run_count': int(workflow.get('run_count') or 0) + 1, 'conversation_id': run_record.get('conversation_id'), + 'active_run_id': '', + 'cancellation_requested_at': None, + 'cancellation_requested_by': '', }, } -def run_group_workflow(workflow, trigger_source='manual', user_roles=None, actor_user_id=None): +def run_group_workflow(workflow, trigger_source='manual', user_roles=None, actor_user_id=None, run_id=None): """Execute a group workflow and persist group-scoped run records.""" workflow = workflow if isinstance(workflow, dict) else {} if not _get_workflow_group_id(workflow): @@ -5901,4 +6254,5 @@ def run_group_workflow(workflow, trigger_source='manual', user_roles=None, actor trigger_source=trigger_source, user_roles=user_roles, actor_user_id=actor_user_id, + run_id=run_id, ) \ No newline at end of file diff --git a/application/single_app/route_backend_workflows.py b/application/single_app/route_backend_workflows.py index 3dce1a63..6b24bf03 100644 --- a/application/single_app/route_backend_workflows.py +++ b/application/single_app/route_backend_workflows.py @@ -48,6 +48,7 @@ list_personal_workflow_run_items, list_personal_workflow_runs, save_personal_workflow, + save_personal_workflow_run, update_personal_workflow_runtime_fields, ) from functions_group import assert_group_role @@ -62,6 +63,7 @@ list_group_workflow_run_items, list_group_workflow_runs, save_group_workflow, + save_group_workflow_run, update_group_workflow_runtime_fields, ) from functions_settings import ( @@ -80,7 +82,7 @@ is_url_access_enabled_for_user, validate_url_access_request, ) -from functions_workflow_runner import run_group_workflow, run_personal_workflow +from functions_workflow_runner import create_workflow_run_id, run_group_workflow, run_personal_workflow from route_backend_agents import ( _build_agent_instruction_api_params, _create_agent_instruction_client, @@ -92,6 +94,10 @@ WORKFLOW_INSTRUCTION_FIELD_LIMIT = 6000 +class WorkflowCancellationConflictError(RuntimeError): + """Raised when a workflow run cannot transition into cancellation.""" + + def _normalize_identifier(value): return str(value or '').strip() @@ -104,6 +110,62 @@ def _normalize_bool(value): return bool(value) +def _request_workflow_run_cancellation( + workflow, + run_id, + requested_by, + get_run, + save_run, + update_runtime_fields, +): + workflow = workflow if isinstance(workflow, dict) else {} + workflow_id = _normalize_identifier(workflow.get('id')) + active_run_id = _normalize_identifier(workflow.get('active_run_id')) + target_run_id = _normalize_identifier(run_id) or active_run_id + if not target_run_id: + raise WorkflowCancellationConflictError('No active workflow run is available to cancel.') + if active_run_id and active_run_id != target_run_id: + raise WorkflowCancellationConflictError('A different workflow run is currently active.') + + run_record = get_run(target_run_id) + if run_record and _normalize_identifier(run_record.get('workflow_id')) != workflow_id: + raise LookupError('Workflow run not found.') + if not run_record and target_run_id != active_run_id: + raise LookupError('Workflow run not found.') + + run_status = _normalize_identifier((run_record or {}).get('status')).lower() + if run_status in {'completed', 'failed', 'skipped', 'cancelled', 'canceled'}: + raise WorkflowCancellationConflictError('This workflow run has already finished.') + + requested_at = datetime.now(timezone.utc).isoformat() + if run_record: + run_record = dict(run_record) + run_record.update({ + 'status': 'cancelling', + 'cancellation_requested_at': run_record.get('cancellation_requested_at') or requested_at, + 'cancellation_requested_by': run_record.get('cancellation_requested_by') or requested_by, + }) + run_record = save_run(run_record) + else: + run_record = { + 'id': target_run_id, + 'workflow_id': workflow_id, + 'status': 'cancelling', + 'cancellation_requested_at': requested_at, + 'cancellation_requested_by': requested_by, + } + run_record = save_run(run_record) + + updated_workflow = update_runtime_fields({ + 'status': 'cancelling', + 'last_run_status': 'cancelling', + 'active_run_id': target_run_id, + 'cancellation_requested_at': run_record.get('cancellation_requested_at') or requested_at, + 'cancellation_requested_by': run_record.get('cancellation_requested_by') or requested_by, + }) + return updated_workflow, run_record + + def _normalize_workflow_instruction_draft_input(value, max_length=WORKFLOW_INSTRUCTION_FIELD_LIMIT): normalized_value = re.sub(r'\s+', ' ', str(value or '')).strip() return normalized_value[:max_length] @@ -551,7 +613,7 @@ def _stream_workflow_activity(user_id, conversation_id='', workflow_id='', run_i yield ': keep-alive\n\n' run_status = str(((snapshot.get('run') or {}).get('status') or '')).strip().lower() - if run_status and run_status != 'running': + if run_status and run_status not in {'running', 'cancelling'}: terminal_snapshots_seen += 1 if terminal_snapshots_seen >= 2: break @@ -584,7 +646,7 @@ def _stream_group_workflow_activity(user_id, group_id, conversation_id='', workf yield ': keep-alive\n\n' run_status = str(((snapshot.get('run') or {}).get('status') or '')).strip().lower() - if run_status and run_status != 'running': + if run_status and run_status not in {'running', 'cancelling'}: terminal_snapshots_seen += 1 if terminal_snapshots_seen >= 2: break @@ -780,6 +842,78 @@ def get_user_workflow_runs(workflow_id): }) + @bp.route('/api/user/workflows//cancel', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + @workflow_user_required + def cancel_active_user_workflow_run(workflow_id): + user_id = get_current_user_id() + workflow = get_personal_workflow(user_id, workflow_id) + if not workflow: + return jsonify({'error': 'Workflow not found.'}), 404 + + try: + updated_workflow, run_record = _request_workflow_run_cancellation( + workflow, + run_id='', + requested_by=user_id, + get_run=lambda requested_run_id: get_personal_workflow_run(user_id, requested_run_id), + save_run=lambda requested_run: save_personal_workflow_run(user_id, requested_run), + update_runtime_fields=lambda updates: update_personal_workflow_runtime_fields(user_id, workflow_id, updates), + ) + except LookupError as exc: + logging.exception("LookupError while cancelling active user workflow run.") + return jsonify({'error': 'Workflow run not found.'}), 404 + except WorkflowCancellationConflictError as exc: + logging.exception("Workflow cancellation conflict while cancelling active user workflow run.") + return jsonify({'error': 'Workflow run cannot be cancelled in its current state.'}), 409 + + return jsonify({'success': True, 'workflow': updated_workflow, 'run': run_record}), 202 + + + @bp.route('/api/user/workflows//runs//cancel', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + @workflow_user_required + def cancel_user_workflow_run(workflow_id, run_id): + user_id = get_current_user_id() + workflow = get_personal_workflow(user_id, workflow_id) + if not workflow: + return jsonify({'error': 'Workflow not found.'}), 404 + + try: + updated_workflow, run_record = _request_workflow_run_cancellation( + workflow, + run_id=run_id, + requested_by=user_id, + get_run=lambda requested_run_id: get_personal_workflow_run(user_id, requested_run_id), + save_run=lambda requested_run: save_personal_workflow_run(user_id, requested_run), + update_runtime_fields=lambda updates: update_personal_workflow_runtime_fields(user_id, workflow_id, updates), + ) + except LookupError: + logging.exception( + "LookupError while cancelling personal workflow run. workflow_id=%s run_id=%s user_id=%s", + workflow_id, + run_id, + user_id, + ) + return jsonify({'error': 'Workflow run not found.'}), 404 + except WorkflowCancellationConflictError: + logging.exception( + "WorkflowCancellationConflictError while cancelling personal workflow run. workflow_id=%s run_id=%s user_id=%s", + workflow_id, + run_id, + user_id, + ) + return jsonify({'error': 'Workflow run cancellation conflict.'}), 409 + + return jsonify({'success': True, 'workflow': updated_workflow, 'run': run_record}), 202 + + @bp.route('/api/user/workflows//runs//items', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required @@ -837,11 +971,15 @@ def resume_failed_user_workflow_items(workflow_id, run_id): try: started_at = datetime.now(timezone.utc).isoformat() + active_run_id = create_workflow_run_id() update_personal_workflow_runtime_fields( user_id, workflow_id, { 'status': 'running', + 'active_run_id': active_run_id, + 'cancellation_requested_at': None, + 'cancellation_requested_by': '', 'last_run_started_at': started_at, 'last_run_trigger_source': 'resume_failed', 'last_run_error': '', @@ -852,10 +990,14 @@ def resume_failed_user_workflow_items(workflow_id, run_id): resume_workflow, trigger_source='resume_failed', user_roles=(session.get('user') or {}).get('roles', []), + run_id=active_run_id, ) update_fields = dict(result.get('workflow_updates') or {}) update_fields['status'] = 'idle' - if workflow.get('trigger_type') in {'interval', 'file_sync'} and workflow.get('is_enabled', False) and not workflow.get('next_run_at'): + run_status = _normalize_identifier((result.get('run') or {}).get('status')).lower() + if workflow.get('trigger_type') in {'interval', 'file_sync'} and workflow.get('is_enabled', False) and ( + not workflow.get('next_run_at') or run_status in {'cancelled', 'canceled'} + ): update_fields['next_run_at'] = compute_next_run_at(workflow, from_time=datetime.now(timezone.utc)) updated_workflow = update_personal_workflow_runtime_fields(user_id, workflow_id, update_fields) @@ -1067,6 +1209,103 @@ def get_group_workflow_runs_route(workflow_id): }) + @bp.route('/api/group/workflows//cancel', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_group_workspaces') + @enabled_required('allow_group_workflows') + def cancel_active_group_workflow_run(workflow_id): + user_id = get_current_user_id() + try: + group_id, _ = _resolve_group_workflow_request_group(user_id) + except ValueError as exc: + logging.exception( + "Invalid group workflow request during cancellation. user_id=%s workflow_id=%s", + user_id, + workflow_id, + ) + return jsonify({'error': 'Invalid group workflow request.'}), 400 + except LookupError as exc: + logging.exception( + "Group workspace lookup failed during workflow cancellation. user_id=%s workflow_id=%s", + user_id, + workflow_id, + ) + return jsonify({'error': 'Group workspace not found.'}), 404 + except PermissionError as exc: + logging.exception( + "Unauthorized group workflow cancellation attempt. user_id=%s workflow_id=%s", + user_id, + workflow_id, + ) + return jsonify({'error': 'Not authorized to access this group workspace.'}), 403 + + workflow = get_group_workflow(group_id, workflow_id) + if not workflow: + return jsonify({'error': 'Workflow not found.'}), 404 + + try: + updated_workflow, run_record = _request_workflow_run_cancellation( + workflow, + run_id='', + requested_by=user_id, + get_run=lambda requested_run_id: get_group_workflow_run(group_id, requested_run_id), + save_run=lambda requested_run: save_group_workflow_run(group_id, requested_run), + update_runtime_fields=lambda updates: update_group_workflow_runtime_fields(group_id, workflow_id, updates), + ) + except LookupError as exc: + logging.exception('Group workflow run cancellation failed: run not found.', exc_info=exc) + return jsonify({'error': 'Workflow run not found.'}), 404 + except WorkflowCancellationConflictError as exc: + return jsonify({'error': str(exc)}), 409 + + return jsonify({'success': True, 'workflow': updated_workflow, 'run': run_record}), 202 + + + @bp.route('/api/group/workflows//runs//cancel', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_group_workspaces') + @enabled_required('allow_group_workflows') + def cancel_group_workflow_run(workflow_id, run_id): + user_id = get_current_user_id() + try: + group_id, _ = _resolve_group_workflow_request_group(user_id) + except ValueError: + logging.exception('Invalid group workflow cancellation request.') + return jsonify({'error': 'Invalid request.'}), 400 + except LookupError: + logging.exception('Group not found while cancelling workflow run.') + return jsonify({'error': 'Group not found.'}), 404 + except PermissionError: + logging.exception('Permission denied while cancelling workflow run.') + return jsonify({'error': 'Forbidden.'}), 403 + + workflow = get_group_workflow(group_id, workflow_id) + if not workflow: + return jsonify({'error': 'Workflow not found.'}), 404 + + try: + updated_workflow, run_record = _request_workflow_run_cancellation( + workflow, + run_id=run_id, + requested_by=user_id, + get_run=lambda requested_run_id: get_group_workflow_run(group_id, requested_run_id), + save_run=lambda requested_run: save_group_workflow_run(group_id, requested_run), + update_runtime_fields=lambda updates: update_group_workflow_runtime_fields(group_id, workflow_id, updates), + ) + except LookupError as exc: + logging.exception('Group workflow run cancellation failed due to missing resource.') + return jsonify({'error': 'Run not found.'}), 404 + except WorkflowCancellationConflictError as exc: + logging.exception('Group workflow run cancellation conflict.') + return jsonify({'error': 'Unable to cancel run in its current state.'}), 409 + + return jsonify({'success': True, 'workflow': updated_workflow, 'run': run_record}), 202 + + @bp.route('/api/group/workflows//runs//items', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required @@ -1149,11 +1388,15 @@ def resume_failed_group_workflow_items(workflow_id, run_id): try: started_at = datetime.now(timezone.utc).isoformat() + active_run_id = create_workflow_run_id() update_group_workflow_runtime_fields( group_id, workflow_id, { 'status': 'running', + 'active_run_id': active_run_id, + 'cancellation_requested_at': None, + 'cancellation_requested_by': '', 'last_run_started_at': started_at, 'last_run_trigger_source': 'resume_failed', 'last_run_error': '', @@ -1165,10 +1408,14 @@ def resume_failed_group_workflow_items(workflow_id, run_id): trigger_source='resume_failed', user_roles=(session.get('user') or {}).get('roles', []), actor_user_id=user_id, + run_id=active_run_id, ) update_fields = dict(result.get('workflow_updates') or {}) update_fields['status'] = 'idle' - if workflow.get('trigger_type') in {'interval', 'file_sync'} and workflow.get('is_enabled', False) and not workflow.get('next_run_at'): + run_status = _normalize_identifier((result.get('run') or {}).get('status')).lower() + if workflow.get('trigger_type') in {'interval', 'file_sync'} and workflow.get('is_enabled', False) and ( + not workflow.get('next_run_at') or run_status in {'cancelled', 'canceled'} + ): update_fields['next_run_at'] = compute_next_run_at(workflow, from_time=datetime.now(timezone.utc)) updated_workflow = update_group_workflow_runtime_fields(group_id, workflow_id, update_fields) @@ -1314,11 +1561,15 @@ def run_group_workflow_route(workflow_id): try: started_at = datetime.now(timezone.utc).isoformat() + active_run_id = create_workflow_run_id() update_group_workflow_runtime_fields( group_id, workflow_id, { 'status': 'running', + 'active_run_id': active_run_id, + 'cancellation_requested_at': None, + 'cancellation_requested_by': '', 'last_run_started_at': started_at, 'last_run_trigger_source': 'manual', 'last_run_error': '', @@ -1330,10 +1581,14 @@ def run_group_workflow_route(workflow_id): trigger_source='manual', user_roles=(session.get('user') or {}).get('roles', []), actor_user_id=user_id, + run_id=active_run_id, ) update_fields = dict(result.get('workflow_updates') or {}) update_fields['status'] = 'idle' - if workflow.get('trigger_type') in {'interval', 'file_sync'} and workflow.get('is_enabled', False) and not workflow.get('next_run_at'): + run_status = _normalize_identifier((result.get('run') or {}).get('status')).lower() + if workflow.get('trigger_type') in {'interval', 'file_sync'} and workflow.get('is_enabled', False) and ( + not workflow.get('next_run_at') or run_status in {'cancelled', 'canceled'} + ): update_fields['next_run_at'] = compute_next_run_at(workflow, from_time=datetime.now(timezone.utc)) updated_workflow = update_group_workflow_runtime_fields(group_id, workflow_id, update_fields) @@ -1460,11 +1715,15 @@ def run_user_workflow(workflow_id): try: started_at = datetime.now(timezone.utc).isoformat() + active_run_id = create_workflow_run_id() update_personal_workflow_runtime_fields( user_id, workflow_id, { 'status': 'running', + 'active_run_id': active_run_id, + 'cancellation_requested_at': None, + 'cancellation_requested_by': '', 'last_run_started_at': started_at, 'last_run_trigger_source': 'manual', 'last_run_error': '', @@ -1475,10 +1734,14 @@ def run_user_workflow(workflow_id): workflow, trigger_source='manual', user_roles=(session.get('user') or {}).get('roles', []), + run_id=active_run_id, ) update_fields = dict(result.get('workflow_updates') or {}) update_fields['status'] = 'idle' - if workflow.get('trigger_type') in {'interval', 'file_sync'} and workflow.get('is_enabled', False) and not workflow.get('next_run_at'): + run_status = _normalize_identifier((result.get('run') or {}).get('status')).lower() + if workflow.get('trigger_type') in {'interval', 'file_sync'} and workflow.get('is_enabled', False) and ( + not workflow.get('next_run_at') or run_status in {'cancelled', 'canceled'} + ): update_fields['next_run_at'] = compute_next_run_at(workflow, from_time=datetime.now(timezone.utc)) updated_workflow = update_personal_workflow_runtime_fields(user_id, workflow_id, update_fields) diff --git a/application/single_app/static/css/workflow-activity.css b/application/single_app/static/css/workflow-activity.css index 619ac1e1..1757611f 100644 --- a/application/single_app/static/css/workflow-activity.css +++ b/application/single_app/static/css/workflow-activity.css @@ -278,6 +278,14 @@ background: #1f6feb; } +.workflow-activity-node[data-status="cancelling"] { + background: #bf8700; +} + +.workflow-activity-node[data-status="cancelled"] { + background: #6f42c1; +} + .workflow-activity-node[data-status="failed"] { background: #cf222e; } @@ -304,6 +312,14 @@ border-left: 4px solid #1f6feb; } +.workflow-activity-card[data-status="cancelling"] { + border-left: 4px solid #bf8700; +} + +.workflow-activity-card[data-status="cancelled"] { + border-left: 4px solid #6f42c1; +} + .workflow-activity-card[data-status="completed"] { border-left: 4px solid #1a7f37; } @@ -362,6 +378,16 @@ color: #1f6feb; } +.workflow-activity-badge[data-status="cancelling"] { + background: rgba(191, 135, 0, 0.14); + color: #8a6100; +} + +.workflow-activity-badge[data-status="cancelled"] { + background: rgba(111, 66, 193, 0.14); + color: #6f42c1; +} + .workflow-activity-badge[data-status="completed"] { background: rgba(26, 127, 55, 0.12); color: #1a7f37; diff --git a/application/single_app/static/js/workflow/workflow-activity.js b/application/single_app/static/js/workflow/workflow-activity.js index 2e8f62ee..d201bde5 100644 --- a/application/single_app/static/js/workflow/workflow-activity.js +++ b/application/single_app/static/js/workflow/workflow-activity.js @@ -21,6 +21,7 @@ const captionEl = document.getElementById("workflow-activity-caption"); const conversationLinkEl = document.getElementById("workflow-activity-conversation-link"); const responseToggleBtn = document.getElementById("workflow-activity-response-toggle"); const responseToggleLabelEl = document.getElementById("workflow-activity-response-toggle-label"); +const cancelRunBtn = document.getElementById("workflow-activity-cancel-btn"); const refreshBtn = document.getElementById("workflow-activity-refresh-btn"); const responseEl = document.getElementById("workflow-activity-response"); const emptyEl = document.getElementById("workflow-activity-empty"); @@ -381,6 +382,11 @@ function buildActivityApiPath(suffix = "") { return `${basePath}${suffix}`; } +function buildWorkflowRunCancellationPath(workflowId, runId) { + const basePath = getActivityScope() === "group" ? "/api/group/workflows" : "/api/user/workflows"; + return `${basePath}/${encodeURIComponent(workflowId)}/runs/${encodeURIComponent(runId)}/cancel`; +} + function buildApiUrl(path) { const url = new URL(path, window.location.origin); const conversationId = getQueryParam("conversationId"); @@ -496,6 +502,8 @@ function applyStatusBadge(element, status) { const normalizedStatus = normalizeText(status).toLowerCase() || "idle"; const className = normalizedStatus === "running" ? "text-bg-primary" + : normalizedStatus === "cancelling" + ? "text-bg-warning" : normalizedStatus === "failed" ? "text-bg-danger" : normalizedStatus === "completed" @@ -503,6 +511,10 @@ function applyStatusBadge(element, status) { : "text-bg-secondary"; const label = normalizedStatus === "running" ? "Running" + : normalizedStatus === "cancelling" + ? "Cancelling" + : normalizedStatus === "cancelled" || normalizedStatus === "canceled" + ? "Cancelled" : normalizedStatus === "failed" ? "Failed" : normalizedStatus === "completed" @@ -513,6 +525,25 @@ function applyStatusBadge(element, status) { element.textContent = label; } +function updateWorkflowCancelButton(workflow, run) { + if (!cancelRunBtn) { + return; + } + + const workflowId = normalizeText(workflow?.id || getQueryParam("workflowId")); + const runId = normalizeText(run?.id || getQueryParam("runId")); + const runStatus = normalizeText(run?.status).toLowerCase(); + const isActive = ["running", "cancelling"].includes(runStatus); + const isCancelling = runStatus === "cancelling"; + const labelEl = cancelRunBtn.querySelector("span"); + + cancelRunBtn.classList.toggle("d-none", !(workflowId && runId && isActive)); + cancelRunBtn.disabled = isCancelling; + if (labelEl) { + labelEl.textContent = isCancelling ? "Cancelling" : "Cancel run"; + } +} + function updateResponseBlock(run) { const errorText = normalizeText(run?.error); const previewText = normalizeText(run?.response_preview); @@ -565,6 +596,7 @@ function renderHeader(snapshot) { conversationLinkEl.classList.toggle("d-none", !conversationUrl); conversationLinkEl.href = conversationUrl || "#"; } + updateWorkflowCancelButton(workflow, run); if (statRunEl) { statRunEl.textContent = run ? normalizeText(run.id).slice(0, 8) || "Captured" : "Pending"; @@ -831,7 +863,7 @@ function shouldListenForUpdates(snapshot) { return true; } - return Boolean(snapshot?.live) || normalizeText(run.status).toLowerCase() === "running"; + return Boolean(snapshot?.live) || ["running", "cancelling"].includes(normalizeText(run.status).toLowerCase()); } function stopEventStream() { @@ -861,7 +893,7 @@ function startEventStream() { }; eventSource.onerror = () => { const runStatus = normalizeText(pageState.snapshot?.run?.status).toLowerCase(); - if (runStatus && runStatus !== "running") { + if (runStatus && !["running", "cancelling"].includes(runStatus)) { stopEventStream(); } }; @@ -929,6 +961,42 @@ if (refreshBtn) { }); } +if (cancelRunBtn) { + cancelRunBtn.addEventListener("click", async () => { + const workflow = pageState.snapshot?.workflow || {}; + const run = pageState.snapshot?.run || {}; + const workflowId = normalizeText(workflow.id || getQueryParam("workflowId")); + const runId = normalizeText(run.id || getQueryParam("runId")); + if (!workflowId || !runId) { + return; + } + + cancelRunBtn.disabled = true; + try { + const response = await fetch(buildApiUrl(buildWorkflowRunCancellationPath(workflowId, runId)), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + body: JSON.stringify({}), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.success === false) { + throw new Error(payload.error || "Unable to cancel the workflow run."); + } + await loadSnapshot(); + } catch (error) { + console.warn("Failed to cancel workflow run", error); + if (captionEl) { + captionEl.textContent = error.message || "Unable to cancel the workflow run."; + } + } finally { + updateWorkflowCancelButton(pageState.snapshot?.workflow || {}, pageState.snapshot?.run || {}); + } + }); +} + if (responseToggleBtn) { responseToggleBtn.addEventListener("click", () => { pageState.responseExpanded = !pageState.responseExpanded; diff --git a/application/single_app/static/js/workspace/workspace_workflows.js b/application/single_app/static/js/workspace/workspace_workflows.js index 8a302b4e..6b23db4c 100644 --- a/application/single_app/static/js/workspace/workspace_workflows.js +++ b/application/single_app/static/js/workspace/workspace_workflows.js @@ -396,7 +396,9 @@ function buildStatusBadge(status) { ? "warning" : normalizedStatus === "running" ? "primary" - : "secondary"; + : normalizedStatus === "cancelling" + ? "warning" + : "secondary"; const label = normalizedStatus.charAt(0).toUpperCase() + normalizedStatus.slice(1); return `${escapeHtml(label)}`; } @@ -1603,37 +1605,42 @@ function buildWorkflowSearchText(workflow) { function getWorkflowDisplayStatus(workflow) { const runtimeStatus = normalizeText(workflow?.status).toLowerCase(); - if (runtimeStatus === "running") { - return "running"; + if (["running", "cancelling"].includes(runtimeStatus)) { + return runtimeStatus; } return normalizeText(workflow?.last_run_status).toLowerCase(); } +function isWorkflowRunActive(workflow) { + return ["running", "cancelling"].includes(getWorkflowDisplayStatus(workflow)); +} + function getWorkflowActivityState(workflow) { const conversationId = normalizeText(workflow?.conversation_id); - const displayStatus = getWorkflowDisplayStatus(workflow); const hasRecordedRun = Boolean(normalizeText(workflow?.last_run_status) || normalizeText(workflow?.last_run_at)); return { - isAvailable: Boolean(conversationId && (displayStatus === "running" || hasRecordedRun)), + isAvailable: Boolean(conversationId && (isWorkflowRunActive(workflow) || hasRecordedRun)), url: buildWorkflowActivityUrl(conversationId, "", normalizeText(workflow?.id)), }; } function getWorkflowRunTimestamp(workflow) { - return getWorkflowDisplayStatus(workflow) === "running" + return isWorkflowRunActive(workflow) ? normalizeText(workflow?.last_run_started_at || workflow?.last_run_at) : normalizeText(workflow?.last_run_at); } function buildWorkflowActionButtons(workflow) { const workflowId = escapeHtml(normalizeText(workflow.id)); - const isRunning = getWorkflowDisplayStatus(workflow) === "running"; const activityState = getWorkflowActivityState(workflow); const buttons = [ - ``, + buildWorkflowRunButton(workflow, true), ]; + if (isWorkflowRunActive(workflow)) { + buttons.push(buildWorkflowCancelButton(workflow, true)); + } if (activityState.isAvailable) { buttons.push(``); } @@ -1647,11 +1654,20 @@ function buildWorkflowActionButtons(workflow) { function buildWorkflowRunButton(workflow, includeLabel = true) { const workflowId = escapeHtml(normalizeText(workflow.id)); - const isRunning = getWorkflowDisplayStatus(workflow) === "running"; - const label = isRunning ? "Running" : "Run"; - const iconClass = isRunning ? "bi bi-hourglass-split" : "bi bi-play-fill"; + const displayStatus = getWorkflowDisplayStatus(workflow); + const isActive = isWorkflowRunActive(workflow); + const label = displayStatus === "cancelling" ? "Cancelling" : displayStatus === "running" ? "Running" : "Run"; + const iconClass = isActive ? "bi bi-hourglass-split" : "bi bi-play-fill"; + const iconSpacing = includeLabel ? " me-1" : ""; + return ``; +} + +function buildWorkflowCancelButton(workflow, includeLabel = true) { + const workflowId = escapeHtml(normalizeText(workflow.id)); + const isCancelling = getWorkflowDisplayStatus(workflow) === "cancelling"; const iconSpacing = includeLabel ? " me-1" : ""; - return ``; + const label = isCancelling ? "Cancelling" : "Cancel"; + return ``; } function buildWorkflowActivityButton(workflow, includeLabel = true) { @@ -1663,10 +1679,14 @@ function buildWorkflowActivityButton(workflow, includeLabel = true) { function buildWorkflowCardMenu(workflow) { const workflowId = escapeHtml(normalizeText(workflow.id)); - const isRunning = getWorkflowDisplayStatus(workflow) === "running"; + const isActive = isWorkflowRunActive(workflow); + const isCancelling = getWorkflowDisplayStatus(workflow) === "cancelling"; const activityState = getWorkflowActivityState(workflow); - const runDisabled = isRunning ? "disabled" : ""; + const runDisabled = isActive ? "disabled" : ""; const activityDisabled = activityState.isAvailable ? "" : "disabled"; + const cancelItem = isActive + ? `
  • ` + : ""; return ` diff --git a/docs/explanation/features/GROUP_WORKFLOWS.md b/docs/explanation/features/GROUP_WORKFLOWS.md index f9b8caa6..456299e8 100644 --- a/docs/explanation/features/GROUP_WORKFLOWS.md +++ b/docs/explanation/features/GROUP_WORKFLOWS.md @@ -3,6 +3,7 @@ Implemented in version: **0.241.179** Enhanced in version: **0.241.193** for configurable workflow agent action limits. Enhanced in version: **0.241.194** with admin capacity guidance for high action limits. +Fixed/Implemented in version: **0.250.062** for active workflow cancellation. Fixed/Implemented in version: **0.241.193** @@ -10,6 +11,7 @@ Related version updates: - `application/single_app/config.py` reported version `0.241.179` when group workflows were implemented. - `application/single_app/config.py` reported version `0.241.193` for workflow action limit configuration. - `application/single_app/config.py` now reports version `0.241.194` for workflow action limit capacity guidance. +- `application/single_app/config.py` now reports version `0.250.062` for active workflow cancellation. ## Overview @@ -42,6 +44,8 @@ API endpoints: - `GET /api/group/workflows/file-sync-sources` - `GET /api/group/workflows//runs` - `POST /api/group/workflows//run` +- `POST /api/group/workflows//cancel` +- `POST /api/group/workflows//runs//cancel` - `POST /api/group/workflows//runs//resume-failed` - `DELETE /api/group/workflows/` - `GET /api/group/workflows/activity` @@ -61,6 +65,7 @@ Permission model: - Authoring and deletion are allowed for Owners and Admins by default. - When owner-only management is enabled, only Owners can create, update, or delete group workflows. - All group workflow API routes revalidate group membership and feature assignment before reading or mutating data. +- Active group runs can be cancelled only after the route resolves the active group and revalidates the caller's current membership. The cancellation request is stored in the group's run partition and is observable by any worker executing that run. File Sync behavior: - Group workflows accept only group-scoped File Sync sources for the active group. @@ -85,6 +90,7 @@ Group workflow: 4. Choose `New Group Workflow`. 5. Enter a name, description, task prompt, runner, document action, alert priority, and trigger. 6. Save and run the workflow manually, or let the scheduler run interval and File Sync monitor workflows. +7. Use `Cancel` from the active workflow row or card, run history, or activity page. The runner stops before beginning further work and marks remaining queued or running document items as `cancelled`; an in-flight external request is allowed to return before the terminal state is recorded. Integration points: - Group agents and merged global agents are available in the group workflow agent picker. @@ -96,9 +102,11 @@ Integration points: Functional coverage: - `functional_tests/test_group_workflows_feature.py` verifies static contracts for storage, settings, routes, scheduler, runner scope, activity deep links, admin UI settings, and group workspace UI wiring. - `functional_tests/test_workflow_auto_invoke_attempt_settings.py` verifies workflow action limit defaults, admin save wiring, Semantic Kernel loader wiring, and workflow runner scoping. +- `functional_tests/test_workflow_cancellation.py` verifies cancellation persistence, personal/group scope boundaries, unfinished item transitions, and terminal runner state. UI coverage: - `ui_tests/test_admin_workflow_settings_access.py` verifies the Admin Settings workflow section exposes the group workflow enablement, assignment, owner-only management controls, and workflow action limit control. +- `ui_tests/test_workflow_cancellation_controls.py` validates shared workspace and activity-page cancellation controls against the group-scoped API contract. Performance and limitations: - Group workflow runtime uses the same scheduler polling cadence and runner path as personal workflows. diff --git a/docs/explanation/features/PERSONAL_WORKFLOWS.md b/docs/explanation/features/PERSONAL_WORKFLOWS.md index 768b1dae..470f693d 100644 --- a/docs/explanation/features/PERSONAL_WORKFLOWS.md +++ b/docs/explanation/features/PERSONAL_WORKFLOWS.md @@ -1,7 +1,7 @@ # Personal Workflows Implemented in version: **0.241.024** -Enhanced in versions: **0.241.029**, **0.241.033**, **0.241.034**, **0.241.035**, **0.241.036**, **0.241.106**, **0.241.179**, **0.241.193**, **0.241.194** +Enhanced in versions: **0.241.029**, **0.241.033**, **0.241.034**, **0.241.035**, **0.241.036**, **0.241.106**, **0.241.179**, **0.241.193**, **0.241.194**, **0.250.062** Implemented in version: **0.241.106** for workflow access governance. @@ -11,11 +11,13 @@ Personal Workflows add an optional workspace capability that lets a user save re Fixed/Implemented in version: **0.241.193** for configurable workflow agent action limits. Enhanced in version: **0.241.194** with admin capacity guidance for high action limits. +Fixed/Implemented in version: **0.250.062** for active workflow cancellation. Related version updates: - `application/single_app/config.py` reported version `0.241.106` for workflow access governance. - `application/single_app/config.py` reported version `0.241.193` for workflow action limit configuration. - `application/single_app/config.py` now reports version `0.241.194` for workflow action limit capacity guidance. +- `application/single_app/config.py` now reports version `0.250.062` for active workflow cancellation. Dependencies: - `application/single_app/functions_personal_workflows.py` @@ -59,6 +61,7 @@ Configuration options: - Scheduled workflows can be paused without deleting the workflow definition. - Users can assign a workflow alert priority of `high`, `medium`, `low`, or `none` for global pop-up notifications after each run. - Users can create, edit, delete, manually run, and inspect run history from the workspace tab. +- Active runs expose `Cancel` controls in the workspace row or card, run history, and workflow activity view. Cancellation is persisted against the active run ID and the workflow returns to idle after the runner records terminal `cancelled` state. File structure: - Backend storage and validation: `application/single_app/functions_personal_workflows.py` @@ -87,9 +90,11 @@ User workflow: 6. Choose a workflow alert priority when you want the run to generate a global pop-up alert modal. 7. Choose `Manual` or `Interval Schedule` and configure the interval when needed. 8. Save the workflow and use `Run` to trigger it immediately or let the scheduler pick it up. +9. Use `Cancel` while a run is active to stop it. The runner stops before starting further File Sync sources, URL access, document analysis, model or agent calls, artifacts, notifications, or other downstream work. An already in-flight external call is allowed to return before cancellation is finalized. Integration points: - Manual runs call `POST /api/user/workflows//run`. +- Cancelling the active run calls `POST /api/user/workflows//cancel`; a run opened from history or activity can be cancelled through `POST /api/user/workflows//runs//cancel`. - Run history is read from `GET /api/user/workflows//runs`. - Scheduler execution uses the same runner path as manual execution. @@ -99,11 +104,13 @@ Functional coverage: - `functional_tests/test_personal_workflows_feature.py` verifies backend wiring, scheduler integration, workspace UI references, and admin toggle presence. - `functional_tests/test_workflow_access_controls.py` verifies workflow defaults, role helpers, route decorators, UI gating snippets, app role deployment definitions, and documentation. - `functional_tests/test_workflow_auto_invoke_attempt_settings.py` verifies workflow action limit defaults, admin save wiring, Semantic Kernel loader wiring, and workflow runner scoping. +- `functional_tests/test_workflow_cancellation.py` verifies cancellation persistence, run ownership checks, personal and group item transitions, runner terminal state, scheduler wiring, and activity contracts. UI coverage: - `ui_tests/test_workspace_workflows_tab.py` validates desktop and mobile rendering, workflow history modal behavior, and new workflow submission from the workspace modal. - `ui_tests/test_workflow_priority_alert_modal.py` validates the global workflow alert modal and mark-read flow. - `ui_tests/test_admin_workflow_settings_access.py` validates the Admin Settings workflow action limit control. +- `ui_tests/test_workflow_cancellation_controls.py` executes the shared workflow and activity modules against personal and group scoped APIs to validate active-run cancellation controls. Performance and limitations: - Group workflow support is available separately through the `Group Workflows` feature. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 3c7c3750..5f27a789 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,16 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.062)** + +#### New Features + +* **Workflow Run Cancellation** + * Personal and group workflows can now be cancelled from workspace rows and cards, run history, or the workflow activity view while a run is active. + * Cancellation is persisted for the active run and cooperatively stops further File Sync, document action, model, agent, artifact, notification, and scheduling work after any in-flight external request returns. + * Cancelled scheduled and File Sync workflows return to an idle state and advance to their next scheduled run instead of immediately restarting. + * (Ref: microsoft/simplechat#990, `route_backend_workflows.py`, `functions_workflow_runner.py`, `workspace_workflows.js`) + ### **(v0.250.061)** #### New Features diff --git a/functional_tests/test_workflow_access_controls.py b/functional_tests/test_workflow_access_controls.py index ae47ad90..da1c5015 100644 --- a/functional_tests/test_workflow_access_controls.py +++ b/functional_tests/test_workflow_access_controls.py @@ -1,8 +1,9 @@ # test_workflow_access_controls.py """ Functional test for workflow access controls. -Version: 0.241.106 +Version: 0.250.062 Implemented in: 0.241.106 +Updated in: 0.250.062 This test ensures personal workflows are an optional feature, can be gated with the WorkflowUser app role, and are enforced across UI and API surfaces. @@ -42,7 +43,7 @@ def test_workflow_access_control_wiring(): deployer_version = read_text("deployers/version.txt").strip() app_roles = json.loads(read_text("deployers/azurecli/appRegistrationRoles.json")) - assert 'VERSION = "0.241.106"' in config_content, ( + assert 'VERSION = "0.250.062"' in config_content, ( "Expected config.py to be bumped for workflow access controls." ) assert 'WORKFLOW_USER_APP_ROLE = "WorkflowUser"' in settings_content, ( @@ -65,12 +66,15 @@ def test_workflow_access_control_wiring(): ) workflow_api_route_count = workflow_routes_content.count("@enabled_required('allow_user_workflows')") - assert workflow_api_route_count == 7, "Expected seven backend personal workflow API route gates." + assert workflow_api_route_count == 12, "Expected twelve backend personal workflow API route gates." assert workflow_routes_content.count("@workflow_user_required") == workflow_api_route_count, ( "Expected every backend workflow API route to require workflow user access." ) - assert "@workflow_user_required" in frontend_chats_content, ( - "Expected the workflow activity page to require workflow user access." + assert "def _authorize_workflow_activity_view(user_id, settings):" in frontend_chats_content, ( + "Expected the workflow activity page to use scope-aware authorization." + ) + assert "authorization_error = _authorize_workflow_activity_view(user_id, settings)" in frontend_chats_content, ( + "Expected the workflow activity route to enforce its scope-aware authorization helper." ) assert "public_settings['allow_user_workflows'] = is_user_workflows_enabled_for_user" in frontend_workspace_content, ( @@ -148,7 +152,10 @@ def test_workflow_access_control_wiring(): assert 'value = "WorkflowUser"' in terraform_content, ( "Expected WorkflowUser app role in Terraform role definitions." ) - assert deployer_version == "1.0.10", "Expected deployer version bump after role definition changes." + deployer_version_parts = deployer_version.split(".") + assert len(deployer_version_parts) == 3 and all(part.isdigit() for part in deployer_version_parts), ( + "Expected deployer version to remain a three-part numeric value." + ) assert "WorkflowUser" in personal_workflows_doc_content, ( "Expected Personal Workflows documentation to describe WorkflowUser." diff --git a/functional_tests/test_workflow_cancellation.py b/functional_tests/test_workflow_cancellation.py new file mode 100644 index 00000000..eead7478 --- /dev/null +++ b/functional_tests/test_workflow_cancellation.py @@ -0,0 +1,389 @@ +# test_workflow_cancellation.py +""" +Functional test for active workflow cancellation. +Version: 0.250.062 +Implemented in: 0.250.062 + +This test ensures personal and group workflow cancellation requests persist by +scope and run id, stop the runner at a cooperative boundary, mark unfinished +items as cancelled, and return the workflow to an idle schedulable state. +""" + +import ast +from datetime import datetime, timezone +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = REPO_ROOT / "application" / "single_app" +RUNNER_FILE = APP_ROOT / "functions_workflow_runner.py" +ROUTES_FILE = APP_ROOT / "route_backend_workflows.py" +BACKGROUND_TASKS_FILE = APP_ROOT / "background_tasks.py" +CONFIG_FILE = APP_ROOT / "config.py" +ACTIVITY_FILE = APP_ROOT / "functions_workflow_activity.py" +WORKSPACE_UI_FILE = APP_ROOT / "static" / "js" / "workspace" / "workspace_workflows.js" +ACTIVITY_UI_FILE = APP_ROOT / "static" / "js" / "workflow" / "workflow-activity.js" +ACTIVITY_CSS_FILE = APP_ROOT / "static" / "css" / "workflow-activity.css" +ACTIVITY_TEMPLATE_FILE = APP_ROOT / "templates" / "workflow_activity.html" +GROUP_TEMPLATE_FILE = APP_ROOT / "templates" / "group_workspaces.html" +RELEASE_NOTES_FILE = REPO_ROOT / "docs" / "explanation" / "release_notes.md" + + +def _read(path): + return path.read_text(encoding="utf-8") + + +def _load_nodes(path, function_names=(), class_names=(), assignment_names=(), namespace=None): + """Load selected top-level definitions without importing application services.""" + module_tree = ast.parse(_read(path), filename=str(path)) + wanted_functions = set(function_names) + wanted_classes = set(class_names) + wanted_assignments = set(assignment_names) + selected_nodes = [] + found_functions = set() + found_classes = set() + found_assignments = set() + + for node in module_tree.body: + if isinstance(node, ast.FunctionDef) and node.name in wanted_functions: + selected_nodes.append(node) + found_functions.add(node.name) + elif isinstance(node, ast.ClassDef) and node.name in wanted_classes: + selected_nodes.append(node) + found_classes.add(node.name) + elif isinstance(node, ast.Assign): + assigned_names = { + target.id + for target in node.targets + if isinstance(target, ast.Name) + } + if assigned_names.intersection(wanted_assignments): + selected_nodes.append(node) + found_assignments.update(assigned_names.intersection(wanted_assignments)) + + missing = ( + (wanted_functions - found_functions) + | (wanted_classes - found_classes) + | (wanted_assignments - found_assignments) + ) + assert not missing, f"Missing selected definitions in {path.name}: {sorted(missing)}" + + selected_module = ast.Module(body=selected_nodes, type_ignores=[]) + ast.fix_missing_locations(selected_module) + resolved_namespace = dict(namespace or {}) + exec(compile(selected_module, str(path), "exec"), resolved_namespace) + return resolved_namespace + + +def _load_cancellation_route_helpers(): + return _load_nodes( + ROUTES_FILE, + function_names=("_request_workflow_run_cancellation",), + class_names=("WorkflowCancellationConflictError",), + namespace={ + "datetime": datetime, + "timezone": timezone, + "_normalize_identifier": lambda value: str(value or "").strip(), + }, + ) + + +def _build_runner_storage_helpers(): + personal_runs = { + ("user-1", "personal-run"): { + "id": "personal-run", + "workflow_id": "personal-workflow", + "status": "cancelling", + "cancellation_requested_at": "2026-07-27T12:00:00+00:00", + "cancellation_requested_by": "user-1", + }, + } + group_runs = { + ("group-1", "group-run"): { + "id": "group-run", + "workflow_id": "group-workflow", + "status": "cancelling", + "cancellation_requested_at": "2026-07-27T12:00:00+00:00", + "cancellation_requested_by": "user-1", + }, + } + personal_workflows = { + ("user-1", "personal-workflow"): { + "id": "personal-workflow", + "user_id": "user-1", + "active_run_id": "personal-run", + "status": "cancelling", + "cancellation_requested_at": "2026-07-27T12:00:00+00:00", + }, + } + group_workflows = { + ("group-1", "group-workflow"): { + "id": "group-workflow", + "user_id": "user-1", + "group_id": "group-1", + "active_run_id": "group-run", + "status": "cancelling", + "cancellation_requested_at": "2026-07-27T12:00:00+00:00", + }, + } + personal_items = { + "personal-run": [ + {"id": "personal-queued", "status": "queued"}, + {"id": "personal-completed", "status": "succeeded"}, + ], + } + group_items = { + "group-run": [ + {"id": "group-running", "status": "running"}, + {"id": "group-failed", "status": "failed"}, + ], + } + saved_items = [] + + namespace = { + "get_personal_workflow_run": lambda user_id, run_id: personal_runs.get((user_id, run_id)), + "get_group_workflow_run": lambda group_id, run_id: group_runs.get((group_id, run_id)), + "get_personal_workflow": lambda user_id, workflow_id: personal_workflows.get((user_id, workflow_id)), + "get_group_workflow": lambda group_id, workflow_id: group_workflows.get((group_id, workflow_id)), + "list_personal_workflow_run_items": lambda run_id, limit=1000: personal_items.get(run_id, []), + "list_group_workflow_run_items": lambda run_id, limit=1000: group_items.get(run_id, []), + "_utc_now_iso": lambda: "2026-07-27T12:01:00+00:00", + "_save_workflow_run_item_record": lambda workflow, item: saved_items.append(dict(item)), + } + helpers = _load_nodes( + RUNNER_FILE, + function_names=( + "_get_workflow_scope", + "_get_workflow_group_id", + "_get_workflow_run_record", + "_get_current_workflow_runtime", + "_list_workflow_run_items", + "_has_workflow_run_cancellation_request", + "_is_workflow_run_cancellation_requested", + "_raise_if_workflow_run_cancelled", + "_execute_cancelable_workflow_step", + "_preserve_workflow_run_cancellation_request", + "_mark_unfinished_workflow_run_items_cancelled", + ), + class_names=("WorkflowRunCancelledError",), + assignment_names=("WORKFLOW_RUN_CANCELLED_MESSAGE",), + namespace=namespace, + ) + return helpers, saved_items + + +def test_cancellation_request_persists_personal_and_group_runs(): + """Cancellation must persist for the exact active run in both workflow scopes.""" + helpers = _load_cancellation_route_helpers() + request_cancellation = helpers["_request_workflow_run_cancellation"] + + for scope, workflow in ( + ( + "personal", + {"id": "personal-workflow", "active_run_id": "personal-run", "status": "running"}, + ), + ( + "group", + {"id": "group-workflow", "active_run_id": "group-run", "status": "running"}, + ), + ): + stored_runs = { + workflow["active_run_id"]: { + "id": workflow["active_run_id"], + "workflow_id": workflow["id"], + "status": "running", + }, + } + runtime_updates = [] + + def get_run(run_id): + return stored_runs.get(run_id) + + def save_run(run_record): + stored_runs[run_record["id"]] = dict(run_record) + return dict(run_record) + + def update_runtime_fields(updates): + runtime_updates.append(dict(updates)) + updated_workflow = dict(workflow) + updated_workflow.update(updates) + return updated_workflow + + updated_workflow, run_record = request_cancellation( + workflow, + run_id="", + requested_by="user-1", + get_run=get_run, + save_run=save_run, + update_runtime_fields=update_runtime_fields, + ) + + assert run_record["status"] == "cancelling", f"Expected {scope} run to enter cancelling state." + assert run_record["cancellation_requested_by"] == "user-1" + assert stored_runs[workflow["active_run_id"]]["status"] == "cancelling" + assert updated_workflow["active_run_id"] == workflow["active_run_id"] + assert runtime_updates[-1]["status"] == "cancelling" + + +def test_early_cancellation_is_saved_and_mismatched_runs_are_rejected(): + """A cancel request must survive before initial save and cannot target another run.""" + helpers = _load_cancellation_route_helpers() + request_cancellation = helpers["_request_workflow_run_cancellation"] + + saved_runs = [] + workflow = {"id": "personal-workflow", "active_run_id": "run-before-initial-save", "status": "running"} + _, early_run = request_cancellation( + workflow, + run_id="", + requested_by="user-1", + get_run=lambda run_id: None, + save_run=lambda run_record: saved_runs.append(dict(run_record)) or dict(run_record), + update_runtime_fields=lambda updates: {**workflow, **updates}, + ) + + assert early_run["status"] == "cancelling" + assert saved_runs == [early_run], "Expected early cancellation to persist a placeholder run record." + + try: + request_cancellation( + workflow, + run_id="run-before-initial-save", + requested_by="user-1", + get_run=lambda run_id: {"id": run_id, "workflow_id": "different-workflow", "status": "running"}, + save_run=lambda run_record: run_record, + update_runtime_fields=lambda updates: {**workflow, **updates}, + ) + except LookupError: + pass + else: + raise AssertionError("Expected a workflow run belonging to another workflow to be rejected.") + + +def test_runner_cancellation_helpers_cover_personal_and_group_items(): + """Persisted requests stop new work and terminalize unfinished items in both scopes.""" + helpers, saved_items = _build_runner_storage_helpers() + personal_workflow = {"id": "personal-workflow", "user_id": "user-1"} + group_workflow = {"id": "group-workflow", "user_id": "user-1", "group_id": "group-1"} + + assert helpers["_is_workflow_run_cancellation_requested"](personal_workflow, "personal-run") + assert helpers["_is_workflow_run_cancellation_requested"](group_workflow, "group-run") + + preserved_personal_run = helpers["_preserve_workflow_run_cancellation_request"]( + personal_workflow, + {"id": "personal-run", "status": "running"}, + ) + preserved_group_run = helpers["_preserve_workflow_run_cancellation_request"]( + group_workflow, + {"id": "group-run", "status": "running"}, + ) + assert preserved_personal_run["status"] == "cancelling" + assert preserved_group_run["status"] == "cancelling" + + helpers["_mark_unfinished_workflow_run_items_cancelled"](personal_workflow, "personal-run") + helpers["_mark_unfinished_workflow_run_items_cancelled"](group_workflow, "group-run") + assert [item["id"] for item in saved_items] == ["personal-queued", "group-running"] + assert all(item["status"] == "cancelled" for item in saved_items) + assert all(item["completed_at"] == "2026-07-27T12:01:00+00:00" for item in saved_items) + + operation_calls = [] + try: + helpers["_execute_cancelable_workflow_step"]( + personal_workflow, + "personal-run", + lambda: operation_calls.append("should-not-run"), + ) + except helpers["WorkflowRunCancelledError"]: + pass + else: + raise AssertionError("Expected a persisted cancellation request to stop the next workflow operation.") + assert operation_calls == [] + + +def test_runner_returns_cancelled_terminal_state_and_clears_active_run(): + """The runner must release its active run state after observing cancellation.""" + saved_runs = [] + cancelled_items = [] + logged_runs = [] + namespace = { + "_get_workflow_group_id": lambda workflow: str(workflow.get("group_id") or ""), + "_get_workflow_scope": lambda workflow: "group" if workflow.get("group_id") else "personal", + "create_workflow_run_id": lambda: "generated-run", + "_utc_now_iso": lambda: "2026-07-27T12:02:00+00:00", + "get_settings": lambda: {}, + "_save_workflow_run_record": lambda workflow, run_record: saved_runs.append(dict(run_record)), + "_raise_if_workflow_run_cancelled": lambda workflow, run_id: None, + "_get_current_workflow_runtime": lambda workflow: { + "cancellation_requested_at": "2026-07-27T12:00:00+00:00", + "cancellation_requested_by": "user-1", + }, + "_mark_unfinished_workflow_run_items_cancelled": lambda workflow, run_id: cancelled_items.append((workflow["id"], run_id)), + "_add_workflow_activity_thought": lambda *args, **kwargs: None, + "log_workflow_run": lambda **kwargs: logged_runs.append(dict(kwargs)), + } + helpers = _load_nodes( + RUNNER_FILE, + function_names=("_finalize_cancelled_workflow_run", "run_personal_workflow"), + class_names=("WorkflowRunCancelledError",), + assignment_names=("WORKFLOW_RUN_CANCELLED_MESSAGE",), + namespace=namespace, + ) + helpers["_raise_if_workflow_run_cancelled"] = lambda workflow, run_id: (_ for _ in ()).throw( + helpers["WorkflowRunCancelledError"]("Workflow cancellation was requested.") + ) + + result = helpers["run_personal_workflow"]( + {"id": "group-workflow", "user_id": "user-1", "group_id": "group-1", "run_count": 2}, + trigger_source="scheduled", + run_id="group-run", + ) + + assert result["success"] is True + assert result["run"]["status"] == "cancelled" + assert result["workflow_updates"]["active_run_id"] == "" + assert result["workflow_updates"]["cancellation_requested_at"] is None + assert result["workflow_updates"]["last_run_status"] == "cancelled" + assert cancelled_items == [("group-workflow", "group-run")] + assert saved_runs[-1]["status"] == "cancelled" + assert logged_runs[-1]["status"] == "cancelled" + assert logged_runs[-1]["workspace_type"] == "group" + + +def test_cancellation_contracts_cover_routes_scheduler_activity_and_shared_ui(): + """Personal and group cancellation must stay wired across all public surfaces.""" + route_source = _read(ROUTES_FILE) + runner_source = _read(RUNNER_FILE) + scheduler_source = _read(BACKGROUND_TASKS_FILE) + activity_source = _read(ACTIVITY_FILE) + workspace_ui_source = _read(WORKSPACE_UI_FILE) + activity_ui_source = _read(ACTIVITY_UI_FILE) + activity_css_source = _read(ACTIVITY_CSS_FILE) + activity_template_source = _read(ACTIVITY_TEMPLATE_FILE) + group_template_source = _read(GROUP_TEMPLATE_FILE) + release_notes_source = _read(RELEASE_NOTES_FILE) + + assert 'VERSION = "0.250.062"' in _read(CONFIG_FILE) + assert "### **(v0.250.062)**" in release_notes_source + assert "Workflow Run Cancellation" in release_notes_source + for route in ( + "/api/user/workflows//cancel", + "/api/user/workflows//runs//cancel", + "/api/group/workflows//cancel", + "/api/group/workflows//runs//cancel", + ): + assert route in route_source, f"Missing cancellation endpoint: {route}" + assert "_resolve_group_workflow_request_group(user_id)" in route_source + assert "create_workflow_run_id" in route_source + assert "create_workflow_run_id" in scheduler_source + assert "WorkflowRunCancelledError" in runner_source + assert "_mark_unfinished_workflow_run_items_cancelled" in runner_source + assert "'last_run_status': 'cancelled'" in runner_source + assert "{'running', 'cancelling'}" in route_source + assert "{'running', 'cancelling'}" in activity_source + assert "run_status in {'cancelled', 'canceled'}" in route_source + assert 'data-action="cancel"' in workspace_ui_source + assert "cancelWorkflow(workflow" in workspace_ui_source + assert "workflow-activity-cancel-btn" in activity_template_source + assert "buildWorkflowRunCancellationPath" in activity_ui_source + assert '.workflow-activity-card[data-status="cancelling"]' in activity_css_source + assert '.workflow-activity-card[data-status="cancelled"]' in activity_css_source + assert "apiBase: '/api/group/workflows'" in group_template_source diff --git a/ui_tests/test_workflow_cancellation_controls.py b/ui_tests/test_workflow_cancellation_controls.py new file mode 100644 index 00000000..6db85a62 --- /dev/null +++ b/ui_tests/test_workflow_cancellation_controls.py @@ -0,0 +1,261 @@ +# test_workflow_cancellation_controls.py +""" +UI test for workflow cancellation controls. +Version: 0.250.062 +Implemented in: 0.250.062 + +This test ensures personal and group workflow views expose a cancel control for +active runs, send the cancellation request to the scoped API, and keep the +control disabled while cancellation is in progress. +""" + +from pathlib import Path +from urllib.parse import urlparse + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKSPACE_MODULE = REPO_ROOT / "application" / "single_app" / "static" / "js" / "workspace" / "workspace_workflows.js" +ACTIVITY_MODULE = REPO_ROOT / "application" / "single_app" / "static" / "js" / "workflow" / "workflow-activity.js" +HARNESS_ORIGIN = "http://workflow-cancellation.test" + +TOAST_MODULE = "export function showToast() {}" +DOCUMENTS_MODULE = "export async function ensureDocumentPickerReady() { return null; } export function setEffectiveScopes() {}" +VIEW_UTILS_MODULE = """ +export function escapeHtml(value) { + return String(value || '').replace(/&/g, '&').replace(//g, '>').replace(/\"/g, '"').replace(/'/g, '''); +} +export function truncateDescription(value, maxLength) { + return String(value || '').slice(0, Number(maxLength) || 0); +} +export function setupViewToggle() {} +export function switchViewContainers() {} +""" + + +def _require_playwright(): + return pytest.importorskip("playwright.sync_api", reason="Install Playwright to run workflow cancellation UI tests.") + + +def _scope_api_base(scope): + return "/api/group/workflows" if scope == "group" else "/api/user/workflows" + + +def _active_workflow(status): + return { + "id": "workflow-active", + "name": "Active Workflow", + "description": "A workflow that is currently running.", + "task_prompt": "Continue processing until cancelled.", + "runner_type": "model", + "trigger_type": "manual", + "is_enabled": True, + "status": status, + "active_run_id": "run-active", + "last_run_status": status, + "last_run_started_at": "2026-07-27T12:00:00+00:00", + "conversation_id": "conversation-active", + "alert_priority": "none", + } + + +def _workspace_harness_html(scope): + api_base = _scope_api_base(scope) + active_group_function = "getActiveGroupId: () => 'group-1'," if scope == "group" else "getActiveGroupId: () => ''," + return f""" + + + + +
    +
    + +
    + + +""" + + +def _activity_harness_html(): + return """ + + + + +
    +
    +

    + +

    + Open workflow + + + +
    +
    +
    +
    +
    +
    +
    +

    +
    +

    +
    +
    
    +            
    +
    +
    + + +""" + + +def _route_workspace_modules(page): + page.route( + "**/static/js/workspace/workspace_workflows.js", + lambda route: route.fulfill(status=200, content_type="text/javascript", body=WORKSPACE_MODULE.read_text(encoding="utf-8")), + ) + page.route( + "**/static/js/chat/chat-toast.js", + lambda route: route.fulfill(status=200, content_type="text/javascript", body=TOAST_MODULE), + ) + page.route( + "**/static/js/chat/chat-documents.js", + lambda route: route.fulfill(status=200, content_type="text/javascript", body=DOCUMENTS_MODULE), + ) + page.route( + "**/static/js/workspace/view-utils.js", + lambda route: route.fulfill(status=200, content_type="text/javascript", body=VIEW_UTILS_MODULE), + ) + + +@pytest.mark.ui +@pytest.mark.parametrize("scope", ["personal", "group"]) +def test_shared_workspace_cancel_control_uses_scoped_api(scope): + """Validate active personal and group workflow rows issue scoped cancel requests.""" + playwright_sync = _require_playwright() + expect = playwright_sync.expect + cancel_paths = [] + state = {"status": "running"} + api_base = _scope_api_base(scope) + + def api_handler(route): + request = route.request + path = urlparse(request.url).path + if request.method == "GET" and path == api_base: + route.fulfill(status=200, content_type="application/json", json={"workflows": [_active_workflow(state["status"])]}) + return + if request.method == "POST" and path == f"{api_base}/workflow-active/cancel": + cancel_paths.append(path) + state["status"] = "cancelling" + route.fulfill( + status=202, + content_type="application/json", + json={"success": True, "workflow": _active_workflow("cancelling"), "run": {"id": "run-active", "status": "cancelling"}}, + ) + return + route.fulfill(status=404, content_type="application/json", json={"error": f"Unexpected API request: {path}"}) + + playwright_manager = playwright_sync.sync_playwright().start() + browser = playwright_manager.chromium.launch() + context = browser.new_context() + page = context.new_page() + _route_workspace_modules(page) + page.route("**/api/**", api_handler) + page.route( + f"{HARNESS_ORIGIN}/", + lambda route: route.fulfill(status=200, content_type="text/html", body=_workspace_harness_html(scope)), + ) + + try: + page.goto(f"{HARNESS_ORIGIN}/", wait_until="domcontentloaded") + workflow_row = page.locator("#workflows-table-body tr").filter(has_text="Active Workflow") + cancel_button = workflow_row.get_by_role("button", name="Cancel workflow run") + expect(cancel_button).to_be_visible() + cancel_button.click() + + expect(workflow_row).to_contain_text("Cancelling") + expect(workflow_row.get_by_role("button", name="Cancel workflow run")).to_be_disabled() + assert cancel_paths == [f"{api_base}/workflow-active/cancel"] + finally: + context.close() + browser.close() + playwright_manager.stop() + + +@pytest.mark.ui +@pytest.mark.parametrize("scope", ["personal", "group"]) +def test_activity_cancel_control_uses_scoped_api(scope): + """Validate the activity page cancels the exact active personal or group run.""" + playwright_sync = _require_playwright() + expect = playwright_sync.expect + cancel_paths = [] + state = {"status": "running"} + api_base = _scope_api_base(scope) + activity_api = f"{api_base}/activity" + + def api_handler(route): + request = route.request + path = urlparse(request.url).path + if request.method == "GET" and path == activity_api: + route.fulfill( + status=200, + content_type="application/json", + json={ + "workflow": {"id": "workflow-active", "name": "Active Workflow"}, + "conversation": {"id": "conversation-active"}, + "run": {"id": "run-active", "status": state["status"], "trigger_source": "manual", "started_at": "2026-07-27T12:00:00+00:00"}, + "activities": [], + "lane_count": 1, + "live": state["status"] in {"running", "cancelling"}, + }, + ) + return + if request.method == "POST" and path == f"{api_base}/workflow-active/runs/run-active/cancel": + cancel_paths.append(path) + state["status"] = "cancelling" + route.fulfill(status=202, content_type="application/json", json={"success": True, "run": {"id": "run-active", "status": "cancelling"}}) + return + route.fulfill(status=404, content_type="application/json", json={"error": f"Unexpected API request: {path}"}) + + playwright_manager = playwright_sync.sync_playwright().start() + browser = playwright_manager.chromium.launch() + context = browser.new_context() + page = context.new_page() + page.route( + "**/static/js/workflow/workflow-activity.js", + lambda route: route.fulfill(status=200, content_type="text/javascript", body=ACTIVITY_MODULE.read_text(encoding="utf-8")), + ) + page.route("**/api/**", api_handler) + page.route( + f"{HARNESS_ORIGIN}/workflow-activity**", + lambda route: route.fulfill(status=200, content_type="text/html", body=_activity_harness_html()), + ) + + try: + group_query = "&scope=group&groupId=group-1" if scope == "group" else "" + page.goto( + f"{HARNESS_ORIGIN}/workflow-activity?workflowId=workflow-active&runId=run-active{group_query}", + wait_until="domcontentloaded", + ) + cancel_button = page.locator("#workflow-activity-cancel-btn") + expect(cancel_button).to_be_visible() + cancel_button.click() + + expect(cancel_button).to_be_disabled() + assert cancel_paths == [f"{api_base}/workflow-active/runs/run-active/cancel"] + finally: + context.close() + browser.close() + playwright_manager.stop()