diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py index 03bcec12d..95d0dec11 100644 --- a/application/single_app/admin_settings_fields.py +++ b/application/single_app/admin_settings_fields.py @@ -4077,6 +4077,18 @@ "default": False, "depends_on": {"key": "allow_user_workflows", "equals": True}, }, + { + "key": "enable_workflow_alert_sounds", + "type": "switch", + "label": "Enable Workflow Alert Sounds", + "help": ( + "Workflow alert rules can play a sound once, or repeat it until " + "someone acknowledges the alert. Turning this off silences every " + "workflow alert sound for everyone, while alerts still pop up and " + "still require acknowledgment." + ), + "default": True, + }, { "key": "allow_group_workflows", "type": "switch", diff --git a/application/single_app/config.py b/application/single_app/config.py index cbd13de68..822816685 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.234" +VERSION = "0.261.235" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_group_workflows.py b/application/single_app/functions_group_workflows.py index db7e5bdef..7facc794c 100644 --- a/application/single_app/functions_group_workflows.py +++ b/application/single_app/functions_group_workflows.py @@ -588,6 +588,7 @@ def build_group_workflow_document(group_id, workflow_data, actor_user_id, user_i workflow_data, existing_workflow=existing_workflow, task_ids=[task.get('id') for task in tasks], + workflow_scope='group', ) alert_priority = alert_settings['alert_priority'] error_handling = _normalize_workflow_error_handling(workflow_data, existing_workflow=existing_workflow) diff --git a/application/single_app/functions_notifications.py b/application/single_app/functions_notifications.py index d445e6a89..b01bf4ecb 100644 --- a/application/single_app/functions_notifications.py +++ b/application/single_app/functions_notifications.py @@ -12,20 +12,29 @@ """ # Imports (grouped after docstring) +import copy import hashlib import json import uuid from datetime import datetime, timedelta, timezone from urllib.parse import urlencode +from azure.core import MatchConditions from azure.cosmos import exceptions from flask import current_app import logging from config import cosmos_notifications_container from functions_appinsights import log_event -from functions_group import find_group_by_id +from functions_group import assert_group_role, get_user_groups +from functions_group_workflow_policy import GROUP_WORKFLOW_MEMBER_ROLES from functions_debug import debug_print from functions_public_workspaces import find_public_workspace_by_id, get_user_public_workspaces from functions_workflow_alert_safety import sanitize_workflow_alert_record +from functions_workflow_alerts import ( + WORKFLOW_ALERT_SIZE_ORDER, + WORKFLOW_ALERT_SOUND_ORDER, + normalize_alert_flag, + resolve_alert_option, +) # Constants TTL_60_DAYS = 60 * 24 * 60 * 60 # 60 days in seconds (5184000) @@ -65,6 +74,35 @@ WORKFLOW_ALERT_FAILURE_CATEGORY = 'failure' WORKFLOW_ALERT_FAILURE_ICON = 'bi-x-octagon' WORKFLOW_ALERT_DELIVERY_NOTIFY_ONLY = 'notify_only' +# Shared notifications are written by many readers, so read, dismiss and acknowledgment writes +# are conditional on the stored version and retried this many times on a conflict. +NOTIFICATION_WRITE_ATTEMPTS = 3 +WORKFLOW_ALERT_ACKNOWLEDGER_NAME_MAX_LENGTH = 200 +# What a group member other than the workflow's owner may read from a team alert. The rest of an +# alert describes the owner's side of the run: its summary and detail can quote conversations and +# actions in the owner's own space, and its links include the owner's workflow conversation and +# private conversations. Members get the alert's facts and links to what the run created in the +# group; the run's output stays in the group's workflow run history. +WORKFLOW_ALERT_MEMBER_METADATA_KEYS = ( + 'workflow_id', + 'workflow_name', + 'workflow_scope', + 'workflow_group_id', + 'priority', + 'category', + 'delivery', + 'require_acknowledgment', + 'sound', + 'size', + 'audience', + 'alert_mode', + 'trigger_reason', + 'trigger_source', + 'run_id', + 'runner_type', + 'status', +) +WORKFLOW_ALERT_MEMBER_RULE_KEYS = ('rule_id', 'rule_name', 'severity', 'condition_type') # Notification type registry for extensibility NOTIFICATION_TYPES = { @@ -322,6 +360,143 @@ def _get_workflow_alert_delivery(notification): return delivery if delivery in {'notify_only', 'popup'} else 'popup' +def _get_workflow_alert_metadata(notification): + metadata = notification.get('metadata') + return metadata if isinstance(metadata, dict) else {} + + +def _workflow_alert_requires_acknowledgment(notification): + return normalize_alert_flag(_get_workflow_alert_metadata(notification).get('require_acknowledgment', False)) + + +def _is_workflow_alert_acknowledged(notification): + return bool(str(notification.get('acknowledged_at') or '').strip()) + + +def _is_team_workflow_alert(notification): + return notification.get('scope') == 'group' and bool(str(notification.get('group_id') or '').strip()) + + +def _reads_team_workflow_alert_as_member(notification, reader_user_id): + """Return True when a team alert must be reduced for this reader. + + Only the workflow's owner reads a team alert in full. A team alert without a recorded owner + is reduced for everyone, so a damaged record can't widen what members see. + """ + if not _is_team_workflow_alert(notification): + return False + owner_user_id = str(_get_workflow_alert_metadata(notification).get('owner_user_id') or '').strip() + return not owner_user_id or owner_user_id != reader_user_id + + +def _join_rule_names(names): + if len(names) <= 1: + return ''.join(names) + return f"{', '.join(names[:-1])} and {names[-1]}" + + +def _member_workflow_alert_links(notification, metadata): + """Keep only links a member can follow: group conversations the run created in this group. + + The workflow's own conversation belongs to its owner, and a personal conversation the agent + created is private, so neither is offered to the rest of the group. + """ + group_id = str(notification.get('group_id') or '').strip() + workflow_conversation_id = str(metadata.get('conversation_id') or '').strip() + links = [] + for target in metadata.get('link_targets') or []: + if not isinstance(target, dict): + continue + link_context = target.get('link_context') if isinstance(target.get('link_context'), dict) else {} + chat_type = str(link_context.get('chat_type') or '').strip().lower() + conversation_id = str(target.get('conversation_id') or link_context.get('conversation_id') or '').strip() + if ( + chat_type.startswith('group') + and str(link_context.get('group_id') or '').strip() == group_id + and conversation_id + and conversation_id != workflow_conversation_id + ): + links.append(copy.deepcopy(target)) + return links + + +def _project_workflow_alert_for_member(notification): + """Return the reduced team alert a group member other than the owner reads.""" + metadata = _get_workflow_alert_metadata(notification) + member_metadata = { + key: copy.deepcopy(metadata[key]) + for key in WORKFLOW_ALERT_MEMBER_METADATA_KEYS + if key in metadata + } + member_metadata['matched_rules'] = [ + {key: match[key] for key in WORKFLOW_ALERT_MEMBER_RULE_KEYS if key in match} + for match in metadata.get('matched_rules') or [] + if isinstance(match, dict) + ] + links = _member_workflow_alert_links(notification, metadata) + member_metadata['link_targets'] = links + + priority = _get_workflow_alert_priority(notification) + workflow_name = str(metadata.get('workflow_name') or '').strip() or 'Workflow' + rule_names = [ + str(match.get('rule_name') or '').strip() + for match in member_metadata['matched_rules'] + ] + rule_names = [name for name in rule_names if name] + + projected = dict(notification) + projected['title'] = f'{priority.capitalize()} priority workflow alert: {workflow_name}' + projected['message'] = ( + f'Matched {_join_rule_names(rule_names)}. Open it for details.' + if rule_names + else 'Open the workflow run for details.' + ) + projected['metadata'] = member_metadata + first_link = links[0] if links else {} + projected['link_url'] = first_link.get('link_url') or '' + projected['link_context'] = copy.deepcopy(first_link.get('link_context') or {}) + return projected + + +def _decorate_workflow_alert(notification, reader_user_id): + """Return a workflow alert as this reader may see it, with its display fields added. + + A team alert is reduced for group members other than the workflow's owner, and every shared + alert reports only the reader's own read and dismissed state, never other members' ids. + """ + if _reads_team_workflow_alert_as_member(notification, reader_user_id): + notification = _project_workflow_alert_for_member(notification) + content_scope = 'member' + else: + notification = dict(notification) + content_scope = 'full' + + metadata = _get_workflow_alert_metadata(notification) + acknowledged_by = notification.get('acknowledged_by') if isinstance(notification.get('acknowledged_by'), dict) else {} + acknowledged = _is_workflow_alert_acknowledged(notification) + + notification['priority'] = _get_workflow_alert_priority(notification) + notification['category'] = _get_workflow_alert_category(notification) + notification['delivery'] = _get_workflow_alert_delivery(notification) + notification['require_acknowledgment'] = normalize_alert_flag(metadata.get('require_acknowledgment', False)) + notification['sound'] = resolve_alert_option(metadata.get('sound'), WORKFLOW_ALERT_SOUND_ORDER) + notification['size'] = resolve_alert_option(metadata.get('size'), WORKFLOW_ALERT_SIZE_ORDER) + notification['audience'] = 'group' if _is_team_workflow_alert(notification) else 'owner' + notification['acknowledged'] = acknowledged + notification['acknowledged_at'] = str(notification.get('acknowledged_at') or '').strip() or None + notification['acknowledged_by_name'] = ( + str(acknowledged_by.get('display_name') or '').strip() or None + ) if acknowledged else None + notification.pop('acknowledged_by', None) + notification['content_scope'] = content_scope + if notification.get('scope') == 'group': + notification['read_by'] = [reader_user_id] if reader_user_id in (notification.get('read_by') or []) else [] + notification['dismissed_by'] = ( + [reader_user_id] if reader_user_id in (notification.get('dismissed_by') or []) else [] + ) + return notification + + def _get_notification_type_config(notification): notification_type = notification.get('notification_type') if notification_type == WORKFLOW_ALERT_NOTIFICATION_TYPE: @@ -818,8 +993,14 @@ def create_workflow_priority_notification( link_url='', link_context=None, metadata=None, + group_id=None, ): - """Create a personal workflow alert notification with a priority-aware display.""" + """Create a workflow alert notification with a priority-aware display. + + The alert is personal to ``user_id`` unless ``group_id`` is given, in which case one shared + group-scoped alert reaches every member of that group. ``metadata['owner_user_id']`` then + names the workflow's owner, the only member who reads the alert in full. + """ normalized_priority = str(priority or 'medium').strip().lower() if normalized_priority not in WORKFLOW_ALERT_PRIORITY_CONFIG: normalized_priority = 'medium' @@ -829,6 +1010,20 @@ def create_workflow_priority_notification( alert_metadata.setdefault('workflow_id', workflow_id) alert_metadata.setdefault('workflow_name', workflow_name) + normalized_group_id = str(group_id or '').strip() + if normalized_group_id: + alert_metadata['audience'] = 'group' + alert_metadata.setdefault('owner_user_id', user_id) + return create_notification( + group_id=normalized_group_id, + notification_type=WORKFLOW_ALERT_NOTIFICATION_TYPE, + title=title, + message=message, + link_url=link_url, + link_context=link_context or {}, + metadata=alert_metadata, + ) + return create_notification( user_id=user_id, notification_type=WORKFLOW_ALERT_NOTIFICATION_TYPE, @@ -959,16 +1154,15 @@ def get_user_notifications(user_id, page=1, per_page=20, include_read=True, incl continue if not include_read and user_id in read_by: continue - + + if notif.get('notification_type') == WORKFLOW_ALERT_NOTIFICATION_TYPE: + notif = _decorate_workflow_alert(notif, user_id) + # Add UI metadata notif['message'] = _get_notification_display_message(notif) notif['is_read'] = user_id in read_by notif['is_dismissed'] = user_id in dismissed_by notif['type_config'] = _get_notification_type_config(notif) - if notif.get('notification_type') == WORKFLOW_ALERT_NOTIFICATION_TYPE: - notif['priority'] = _get_workflow_alert_priority(notif) - notif['category'] = _get_workflow_alert_category(notif) - notif['delivery'] = _get_workflow_alert_delivery(notif) filtered_notifications.append(notif) @@ -1151,14 +1345,7 @@ def get_unread_workflow_priority_notifications(user_id, limit=5, since_hours=Non if _get_workflow_alert_delivery(notification) == WORKFLOW_ALERT_DELIVERY_NOTIFY_ONLY: continue - notification['message'] = _get_notification_display_message(notification) - notification['is_read'] = False - notification['is_dismissed'] = False - notification['priority'] = _get_workflow_alert_priority(notification) - notification['category'] = _get_workflow_alert_category(notification) - notification['delivery'] = _get_workflow_alert_delivery(notification) - notification['type_config'] = _get_notification_type_config(notification) - unread_notifications.append(notification) + unread_notifications.append(_present_workflow_alert_popup(notification, user_id)) if len(unread_notifications) >= normalized_limit: break @@ -1171,9 +1358,315 @@ def get_unread_workflow_priority_notifications(user_id, limit=5, since_hours=Non return [] +def _normalize_workflow_alert_limit(limit): + try: + return max(1, min(int(limit or 5), 10)) + except (TypeError, ValueError): + return 5 + + +def _present_workflow_alert_popup(notification, user_id): + """Decorate one workflow alert for a pop-up reader, reporting only that reader's state.""" + read_by = notification.get('read_by') or [] + dismissed_by = notification.get('dismissed_by') or [] + is_read = user_id in read_by + is_dismissed = user_id in dismissed_by + notification = _decorate_workflow_alert(notification, user_id) + notification['message'] = _get_notification_display_message(notification) + notification['is_read'] = is_read + notification['is_dismissed'] = is_dismissed + notification['type_config'] = _get_notification_type_config(notification) + return notification + + +def _get_user_group_ids(user_id): + """Return the ids of the groups the user belongs to now, read from the groups store.""" + return sorted({ + str(group.get('id') or '').strip() + for group in get_user_groups(user_id) or [] + if isinstance(group, dict) and str(group.get('id') or '').strip() + }) + + +def _read_workflow_alert_page(query_parts, parameters, partition_key=None): + query = ' '.join(query_parts) + if partition_key is not None: + return list(cosmos_notifications_container.query_items( + query=query, + parameters=parameters, + partition_key=partition_key, + )) + return list(cosmos_notifications_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True, + )) + + +# Must-acknowledge alerts keep popping up until acknowledged, whatever their age or the reader's +# read state, so they are read separately and can't be crowded out of TOP by newer alerts. +_WORKFLOW_ALERT_UNACKNOWLEDGED_FILTERS = [ + 'AND c.notification_type = @notification_type', + 'AND c.metadata.require_acknowledgment = true', + "AND (NOT IS_DEFINED(c.acknowledged_at) OR IS_NULL(c.acknowledged_at) OR c.acknowledged_at = '')", + 'AND (NOT IS_STRING(c.metadata.delivery) OR LOWER(TRIM(c.metadata.delivery)) != @notify_only)', +] + + +def _is_pending_acknowledgment_popup(notification): + return ( + _workflow_alert_requires_acknowledgment(notification) + and not _is_workflow_alert_acknowledged(notification) + and _get_workflow_alert_delivery(notification) != WORKFLOW_ALERT_DELIVERY_NOTIFY_ONLY + ) + + +def get_workflow_alert_popups(user_id, limit=5, since_hours=None, raise_on_error=False): + """Return every workflow alert that should pop up for a user, and whether that list is whole. + + Four bounded reads, each capped at ``limit``: + + - the user's unread pop-up alerts (``get_unread_workflow_priority_notifications``); + - the user's must-acknowledge alerts nobody has acknowledged, whatever their age or read state; + - unread pop-up team alerts from the groups the user belongs to now; + - unacknowledged must-acknowledge team alerts from those groups. + + ``since_hours`` narrows only the unread reads. ``complete`` is False when any read came back + full, so a client can't take a capped answer for every alert there is. Team alerts are reduced + for members other than the workflow's owner (``_decorate_workflow_alert``). + + Returns ``{'notifications': [...], 'complete': bool}``, newest first. + """ + normalized_limit = _normalize_workflow_alert_limit(limit) + normalized_since_hours = parse_workflow_alert_since_hours(since_hours) + popups = {} + complete = True + + def keep(notifications): + for notification in notifications: + popups.setdefault(notification.get('id'), notification) + + unread = get_unread_workflow_priority_notifications( + user_id, + limit=normalized_limit, + since_hours=normalized_since_hours, + raise_on_error=raise_on_error, + ) + complete = len(unread) < normalized_limit + keep(unread) + + base_parameters = [ + {'name': '@limit', 'value': normalized_limit}, + {'name': '@user_id', 'value': user_id}, + {'name': '@notification_type', 'value': WORKFLOW_ALERT_NOTIFICATION_TYPE}, + {'name': '@notify_only', 'value': WORKFLOW_ALERT_DELIVERY_NOTIFY_ONLY}, + ] + try: + pending = _read_workflow_alert_page( + ['SELECT TOP @limit * FROM c', 'WHERE c.user_id = @user_id', *_WORKFLOW_ALERT_UNACKNOWLEDGED_FILTERS, + 'ORDER BY c.created_at DESC'], + base_parameters, + partition_key=user_id, + ) + complete = complete and len(pending) < normalized_limit + keep( + _present_workflow_alert_popup(notification, user_id) + for notification in (sanitize_workflow_alert_record(item) for item in pending) + if notification.get('user_id') == user_id and _is_pending_acknowledgment_popup(notification) + ) + + group_ids = _get_user_group_ids(user_id) + if group_ids: + team_parameters = [*base_parameters, {'name': '@group_ids', 'value': group_ids}] + team_unread_parts = [ + 'SELECT TOP @limit * FROM c', + 'WHERE ARRAY_CONTAINS(@group_ids, c.group_id)', + "AND c.scope = 'group'", + 'AND c.notification_type = @notification_type', + 'AND (NOT IS_ARRAY(c.read_by) OR NOT ARRAY_CONTAINS(c.read_by, @user_id))', + 'AND (NOT IS_ARRAY(c.dismissed_by) OR NOT ARRAY_CONTAINS(c.dismissed_by, @user_id))', + 'AND (NOT IS_STRING(c.metadata.delivery) OR LOWER(TRIM(c.metadata.delivery)) != @notify_only)', + 'AND (NOT IS_DEFINED(c.metadata.require_acknowledgment) OR c.metadata.require_acknowledgment != true)', + ] + team_unread_parameters = list(team_parameters) + if normalized_since_hours is not None: + created_after = (datetime.now(timezone.utc) - timedelta(hours=normalized_since_hours)).isoformat() + team_unread_parts.append('AND c.created_at >= @created_after') + team_unread_parameters.append({'name': '@created_after', 'value': created_after}) + team_unread_parts.append('ORDER BY c.created_at DESC') + + team_unread = _read_workflow_alert_page(team_unread_parts, team_unread_parameters) + complete = complete and len(team_unread) < normalized_limit + keep( + _present_workflow_alert_popup(notification, user_id) + for notification in (sanitize_workflow_alert_record(item) for item in team_unread) + if _is_team_workflow_alert(notification) + and notification.get('group_id') in group_ids + and user_id not in (notification.get('read_by') or []) + and user_id not in (notification.get('dismissed_by') or []) + and _get_workflow_alert_delivery(notification) != WORKFLOW_ALERT_DELIVERY_NOTIFY_ONLY + and not _workflow_alert_requires_acknowledgment(notification) + ) + + team_pending = _read_workflow_alert_page( + ['SELECT TOP @limit * FROM c', 'WHERE ARRAY_CONTAINS(@group_ids, c.group_id)', "AND c.scope = 'group'", + *_WORKFLOW_ALERT_UNACKNOWLEDGED_FILTERS, 'ORDER BY c.created_at DESC'], + [parameter for parameter in team_parameters if parameter['name'] != '@user_id'], + ) + complete = complete and len(team_pending) < normalized_limit + keep( + _present_workflow_alert_popup(notification, user_id) + for notification in (sanitize_workflow_alert_record(item) for item in team_pending) + if _is_team_workflow_alert(notification) + and notification.get('group_id') in group_ids + and _is_pending_acknowledgment_popup(notification) + ) + except Exception as e: + log_event( + '[NOTIFICATIONS] Workflow alert pop-up read failed.', + extra={'user_id': user_id, 'error_type': type(e).__name__}, + level=logging.WARNING, + ) + if raise_on_error: + raise + complete = False + + ordered = sorted(popups.values(), key=lambda item: str(item.get('created_at') or ''), reverse=True) + return {'notifications': ordered, 'complete': complete} + + +def _read_notification_by_id(notification_id): + """Find a notification by id across every scope's partition.""" + notifications = list(cosmos_notifications_container.query_items( + query='SELECT * FROM c WHERE c.id = @notification_id', + parameters=[{'name': '@notification_id', 'value': notification_id}], + enable_cross_partition_query=True, + )) + return notifications[0] if notifications else None + + +def _write_notification_change(notification_id, change): + """Apply ``change`` to a stored notification, writing only if nobody changed it meanwhile. + + ``change(notification)`` edits the document in place and returns True when it changed + anything. Group and workspace notifications are shared by many readers, so writing a stale + copy unconditionally could drop another reader's read or dismissal, or undo a workflow + alert's acknowledgment. The write is conditional on the stored ETag and retried on a conflict. + + Returns the stored notification, or None when it doesn't exist or has no partition key. + """ + for _attempt in range(NOTIFICATION_WRITE_ATTEMPTS): + notification = _read_notification_by_id(notification_id) + if not notification or not _get_notification_partition_key(notification): + return None + if not change(notification): + return notification + etag = notification.get('_etag') + if not etag: + return cosmos_notifications_container.upsert_item(notification) + try: + return cosmos_notifications_container.replace_item( + item=notification['id'], + body=notification, + etag=etag, + match_condition=MatchConditions.IfNotModified, + ) + except exceptions.CosmosAccessConditionFailedError: + continue + raise exceptions.CosmosAccessConditionFailedError(status_code=412, message='Notification changed.') + + +def _can_receive_workflow_alert(notification, user_id): + """Return True when this user is one of the alert's recipients right now. + + A personal alert belongs to its owner. A team alert belongs to the current members of its + group, re-checked against the groups store rather than taken from the request. + """ + if not user_id: + return False + if _is_team_workflow_alert(notification): + try: + assert_group_role( + user_id, + str(notification.get('group_id') or '').strip(), + allowed_roles=GROUP_WORKFLOW_MEMBER_ROLES, + ) + except (LookupError, PermissionError): + return False + return True + return notification.get('scope') in (None, 'personal') and notification.get('user_id') == user_id + + +def acknowledge_workflow_alert(notification_id, user_id, display_name=''): + """Acknowledge a must-acknowledge workflow alert for everyone who receives it. + + The caller must be a recipient: the owner of a personal alert, or a current member of a team + alert's group. Anything else is answered as ``not_found``, so the response never confirms an + alert the caller can't see. The first acknowledgment wins: the write is conditional on the + stored ETag, and an alert someone already acknowledged keeps that acknowledgment. The + caller's own read state is set either way. + + Returns ``(status, notification)``. ``status`` is ``acknowledged``, ``already_acknowledged``, + ``not_found`` or ``not_required``; ``notification`` is the alert decorated for the caller, + or None. + """ + normalized_id = str(notification_id or '').strip() + if not normalized_id or not user_id: + return 'not_found', None + + outcome = {'status': 'not_found'} + acknowledger_name = ' '.join(str(display_name or '').split())[:WORKFLOW_ALERT_ACKNOWLEDGER_NAME_MAX_LENGTH] + + def acknowledge(notification): + if ( + notification.get('notification_type') != WORKFLOW_ALERT_NOTIFICATION_TYPE + or not _can_receive_workflow_alert(notification, user_id) + ): + outcome['status'] = 'not_found' + return False + if not _workflow_alert_requires_acknowledgment(notification): + outcome['status'] = 'not_required' + return False + + changed = False + if _is_workflow_alert_acknowledged(notification): + outcome['status'] = 'already_acknowledged' + else: + outcome['status'] = 'acknowledged' + notification['acknowledged_at'] = datetime.now(timezone.utc).isoformat() + notification['acknowledged_by'] = {'user_id': user_id, 'display_name': acknowledger_name} + changed = True + read_by = list(notification.get('read_by') or []) + if user_id not in read_by: + read_by.append(user_id) + notification['read_by'] = read_by + changed = True + return changed + + stored = _write_notification_change(normalized_id, acknowledge) + if stored is None or outcome['status'] in ('not_found', 'not_required'): + return outcome['status'], None + + if outcome['status'] == 'acknowledged': + log_event( + '[NOTIFICATIONS] Workflow alert acknowledged.', + extra={ + 'notification_id': normalized_id, + 'user_id': user_id, + 'scope': stored.get('scope'), + 'group_id': stored.get('group_id'), + 'workflow_id': _get_workflow_alert_metadata(stored).get('workflow_id'), + 'run_id': _get_workflow_alert_metadata(stored).get('run_id'), + }, + ) + return outcome['status'], _present_workflow_alert_popup(sanitize_workflow_alert_record(stored), user_id) + + def mark_notification_read(notification_id, user_id): """ Mark a notification as read by a specific user. + + Reading never acknowledges a workflow alert that must be acknowledged. Args: notification_id (str): Notification ID @@ -1183,38 +1676,20 @@ def mark_notification_read(notification_id, user_id): bool: True if successful, False otherwise """ try: - # First, find the notification across all partition keys - query = "SELECT * FROM c WHERE c.id = @notification_id" - params = [{"name": "@notification_id", "value": notification_id}] - - notifications = list(cosmos_notifications_container.query_items( - query=query, - parameters=params, - enable_cross_partition_query=True - )) - - if not notifications: - debug_print(f"Notification {notification_id} not found") - return False - - notification = notifications[0] - - # Determine partition key - partition_key = _get_notification_partition_key(notification) - - if not partition_key: - debug_print(f"No partition key found for notification {notification_id}") - return False - - # Add user to read_by if not already present - read_by = notification.get('read_by', []) - if user_id not in read_by: + def mark_read(notification): + read_by = list(notification.get('read_by') or []) + if user_id in read_by: + return False read_by.append(user_id) notification['read_by'] = read_by - - cosmos_notifications_container.upsert_item(notification) - debug_print(f"Notification {notification_id} marked read by {user_id}") - + return True + + stored = _write_notification_change(notification_id, mark_read) + if stored is None: + debug_print(f"Notification {notification_id} not found") + return False + + debug_print(f"Notification {notification_id} marked read by {user_id}") return True except Exception as e: @@ -1305,6 +1780,8 @@ def mark_collaboration_message_notifications_read_for_conversation(user_id, conv def dismiss_notification(notification_id, user_id): """ Dismiss a notification for a specific user (adds to dismissed_by). + + Dismissing never acknowledges a workflow alert that must be acknowledged. Args: notification_id (str): Notification ID @@ -1314,38 +1791,20 @@ def dismiss_notification(notification_id, user_id): bool: True if successful, False otherwise """ try: - # Find notification across all partitions - query = "SELECT * FROM c WHERE c.id = @notification_id" - params = [{"name": "@notification_id", "value": notification_id}] - - notifications = list(cosmos_notifications_container.query_items( - query=query, - parameters=params, - enable_cross_partition_query=True - )) - - if not notifications: - debug_print(f"Notification {notification_id} not found") - return False - - notification = notifications[0] - - # Determine partition key - partition_key = _get_notification_partition_key(notification) - - if not partition_key: - debug_print(f"No partition key found for notification {notification_id}") - return False - - # Add user to dismissed_by - dismissed_by = notification.get('dismissed_by', []) - if user_id not in dismissed_by: + def dismiss(notification): + dismissed_by = list(notification.get('dismissed_by') or []) + if user_id in dismissed_by: + return False dismissed_by.append(user_id) notification['dismissed_by'] = dismissed_by - - cosmos_notifications_container.upsert_item(notification) - debug_print(f"Notification {notification_id} dismissed by {user_id}") - + return True + + stored = _write_notification_change(notification_id, dismiss) + if stored is None: + debug_print(f"Notification {notification_id} not found") + return False + + debug_print(f"Notification {notification_id} dismissed by {user_id}") return True except Exception as e: diff --git a/application/single_app/functions_personal_workflows.py b/application/single_app/functions_personal_workflows.py index 1b9aa57ea..ad1df3a3e 100644 --- a/application/single_app/functions_personal_workflows.py +++ b/application/single_app/functions_personal_workflows.py @@ -971,6 +971,7 @@ def build_personal_workflow_document(user_id, workflow_data, actor_user_id=None, workflow_data, existing_workflow=existing_workflow, task_ids=[task.get('id') for task in tasks], + workflow_scope='personal', ) alert_priority = alert_settings['alert_priority'] error_handling = _normalize_workflow_error_handling(workflow_data, existing_workflow=existing_workflow) diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index b1c0e1343..70ca0adab 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -1539,6 +1539,8 @@ def get_settings(use_cosmos=False, include_source=False): 'enable_workflow_ai_assistant': True, # Lets users ask chat about the stored results of their personal workflow runs. 'enable_chat_workflow_results': False, + # Lets workflow alert rules play one-shot or repeating browser sounds. + 'enable_workflow_alert_sounds': True, 'workflow_max_tasks': 50, 'workflow_max_loop_items': WORKFLOW_LOOP_ITEMS_DEFAULT, 'workflow_max_repeat_iterations': WORKFLOW_REPEAT_ITERATIONS_DEFAULT, @@ -2287,6 +2289,12 @@ def update_settings(new_settings, *, expected_etag=None): **new_settings, 'enable_chat_workflow_results': new_settings['enable_chat_workflow_results'] is True, } + if isinstance(new_settings, dict) and 'enable_workflow_alert_sounds' in new_settings: + # Only a real boolean false silences workflow alert sounds; a string or number keeps the default on. + new_settings = { + **new_settings, + 'enable_workflow_alert_sounds': new_settings['enable_workflow_alert_sounds'] is not False, + } if isinstance(new_settings, dict) and 'workflow_min_schedule_interval_seconds' in new_settings: new_settings = { **new_settings, diff --git a/application/single_app/functions_workflow_activity.py b/application/single_app/functions_workflow_activity.py index a47d68c36..06f936888 100644 --- a/application/single_app/functions_workflow_activity.py +++ b/application/single_app/functions_workflow_activity.py @@ -4,7 +4,15 @@ from datetime import datetime, timezone -from functions_workflow_alerts import describe_alert_condition, resolve_workflow_alert_config +from functions_workflow_alerts import ( + WORKFLOW_ALERT_AUDIENCE_ORDER, + WORKFLOW_ALERT_SIZE_ORDER, + WORKFLOW_ALERT_SOUND_ORDER, + describe_alert_condition, + normalize_alert_flag, + resolve_alert_option, + resolve_workflow_alert_config, +) from functions_workflow_alert_safety import sanitize_workflow_alert_record from functions_m365_workflow_binding import M365_ACTIVE_STATES @@ -69,6 +77,10 @@ def _serialize_workflow(workflow): 'enabled': rule.get('enabled', True), 'severity': rule.get('severity'), 'delivery': rule.get('delivery'), + 'require_acknowledgment': normalize_alert_flag(rule.get('require_acknowledgment', False)), + 'sound': resolve_alert_option(rule.get('sound'), WORKFLOW_ALERT_SOUND_ORDER), + 'size': resolve_alert_option(rule.get('size'), WORKFLOW_ALERT_SIZE_ORDER), + 'audience': resolve_alert_option(rule.get('audience'), WORKFLOW_ALERT_AUDIENCE_ORDER), 'condition_summary': describe_alert_condition(rule.get('condition')), } for rule in alert_config.get('alert_rules') or [] diff --git a/application/single_app/functions_workflow_alerts.py b/application/single_app/functions_workflow_alerts.py index 3ed588e87..0e28b5f19 100644 --- a/application/single_app/functions_workflow_alerts.py +++ b/application/single_app/functions_workflow_alerts.py @@ -43,6 +43,16 @@ WORKFLOW_ALERT_RESOLVED_DELIVERIES = {'notify_only', 'popup'} WORKFLOW_ALERT_LEGACY_PRIORITIES = {'none', 'low', 'medium', 'high'} +# Pop-up options a rule can ask for, each ordered from the default to the strongest. When +# several rules match one run, every option takes the strongest value any of them asked for. +WORKFLOW_ALERT_SOUND_ORDER = ('off', 'once', 'repeat') +WORKFLOW_ALERT_SIZE_ORDER = ('small', 'medium', 'large') +WORKFLOW_ALERT_AUDIENCE_ORDER = ('owner', 'group') +WORKFLOW_ALERT_DEFAULT_SOUND = WORKFLOW_ALERT_SOUND_ORDER[0] +WORKFLOW_ALERT_DEFAULT_SIZE = WORKFLOW_ALERT_SIZE_ORDER[0] +WORKFLOW_ALERT_DEFAULT_AUDIENCE = WORKFLOW_ALERT_AUDIENCE_ORDER[0] +WORKFLOW_ALERT_WORKFLOW_SCOPES = {'personal', 'group'} + # Quiet severities land in the notification bell; louder ones interrupt with the modal. WORKFLOW_ALERT_SEVERITY_DELIVERY = { 'info': 'notify_only', @@ -127,6 +137,42 @@ def resolve_alert_delivery(severity, delivery): return get_default_alert_delivery(severity) +def normalize_alert_flag(value): + """Read a stored or posted on/off value the way ``enabled`` has always been read.""" + if isinstance(value, str): + return value.strip().lower() in {'1', 'true', 'yes', 'on'} + return bool(value) + + +def resolve_alert_option(value, order): + """Return a supported option from ``order``, or its default (the first) when unsupported. + + Used when reading rules and alerts that were already saved, so a damaged value falls back + quietly; the save path rejects it instead. + """ + normalized = str(value or '').strip().lower() + return normalized if normalized in order else order[0] + + +def strongest_alert_option(values, order): + """Return the strongest supported value in ``values`` by its position in ``order``.""" + strongest = 0 + for value in values: + normalized = str(value or '').strip().lower() + if normalized in order: + strongest = max(strongest, order.index(normalized)) + return order[strongest] + + +def alert_options_need_popup(require_acknowledgment, sound, size): + """Return True when an alert's options only make sense as a pop-up.""" + return ( + bool(require_acknowledgment) + or sound != WORKFLOW_ALERT_DEFAULT_SOUND + or size != WORKFLOW_ALERT_DEFAULT_SIZE + ) + + def _truncate(text, limit): normalized = str(text or '') if len(normalized) <= limit: @@ -378,8 +424,20 @@ def describe_alert_condition(condition): return label -def normalize_alert_rule(raw_rule, task_ids=None, index=0): - """Normalize and validate a single alert rule.""" +def _normalize_rule_option(raw_rule, key, order, position, label): + """Read one pop-up option on the save path, rejecting unsupported values by position.""" + value = str(raw_rule.get(key) or order[0]).strip().lower() or order[0] + if value not in order: + raise WorkflowAlertValidationError(f'Alert rule {position} {label} must be {_or_list(order)}.') + return value + + +def normalize_alert_rule(raw_rule, task_ids=None, index=0, workflow_scope=None): + """Normalize and validate a single alert rule. + + ``workflow_scope`` is ``'personal'`` or ``'group'`` when the caller knows where the workflow + lives; ``None`` skips the check that only group workflows can alert the whole group. + """ position = index + 1 if not isinstance(raw_rule, dict): raise WorkflowAlertValidationError(f'Alert rule {position} is invalid.') @@ -411,19 +469,56 @@ def normalize_alert_rule(raw_rule, task_ids=None, index=0): if isinstance(enabled, str): enabled = enabled.strip().lower() in {'1', 'true', 'yes', 'on'} - return { + require_acknowledgment = normalize_alert_flag(raw_rule.get('require_acknowledgment', False)) + sound = _normalize_rule_option(raw_rule, 'sound', WORKFLOW_ALERT_SOUND_ORDER, position, 'sound') + size = _normalize_rule_option(raw_rule, 'size', WORKFLOW_ALERT_SIZE_ORDER, position, 'size') + audience = _normalize_rule_option(raw_rule, 'audience', WORKFLOW_ALERT_AUDIENCE_ORDER, position, 'audience') + if ( + audience == 'group' + and workflow_scope in WORKFLOW_ALERT_WORKFLOW_SCOPES + and workflow_scope != 'group' + ): + raise WorkflowAlertValidationError( + f'Alert rule {position} can alert the whole group only in a group workflow.' + ) + if ( + resolve_alert_delivery(severity, delivery) == 'notify_only' + and alert_options_need_popup(require_acknowledgment, sound, size) + ): + raise WorkflowAlertValidationError( + f'Alert rule {position} must pop up to require acknowledgment, play a sound or change its size.' + ) + if sound == 'repeat' and not require_acknowledgment: + raise WorkflowAlertValidationError( + f'Alert rule {position} can repeat its sound only when it requires acknowledgment.' + ) + + normalized_rule = { 'id': str(raw_rule.get('id') or '').strip() or str(uuid.uuid4()), 'name': name, 'enabled': bool(enabled), 'severity': severity, 'delivery': delivery, + } + # Pop-up options are stored only when they differ from the default, so rules saved before + # they existed keep exactly the shape they had. + if require_acknowledgment: + normalized_rule['require_acknowledgment'] = True + if sound != WORKFLOW_ALERT_DEFAULT_SOUND: + normalized_rule['sound'] = sound + if size != WORKFLOW_ALERT_DEFAULT_SIZE: + normalized_rule['size'] = size + if audience != WORKFLOW_ALERT_DEFAULT_AUDIENCE: + normalized_rule['audience'] = audience + normalized_rule.update({ 'scope': scope, 'condition': condition, 'order': index + 1, - } + }) + return normalized_rule -def normalize_alert_rules(raw_rules, task_ids=None): +def normalize_alert_rules(raw_rules, task_ids=None, workflow_scope=None): """Normalize a list of alert rules, enforcing the rule limit and unique ids.""" if raw_rules is None: return [] @@ -435,7 +530,12 @@ def normalize_alert_rules(raw_rules, task_ids=None): normalized_rules = [] seen_rule_ids = set() for index, raw_rule in enumerate(raw_rules): - normalized_rule = normalize_alert_rule(raw_rule, task_ids=task_ids, index=index) + normalized_rule = normalize_alert_rule( + raw_rule, + task_ids=task_ids, + index=index, + workflow_scope=workflow_scope, + ) if normalized_rule['id'] in seen_rule_ids: normalized_rule['id'] = str(uuid.uuid4()) seen_rule_ids.add(normalized_rule['id']) @@ -525,22 +625,31 @@ def resolve_workflow_alert_config(workflow): } -def normalize_workflow_alert_settings(workflow_data, existing_workflow=None, task_ids=None): +def normalize_workflow_alert_settings(workflow_data, existing_workflow=None, task_ids=None, workflow_scope=None): """Normalize the alert fields for a workflow save request. Returns a dict with ``alert_mode``, ``alert_priority``, ``alert_rules`` and - ``alert_evaluation`` ready to persist on the workflow document. + ``alert_evaluation`` ready to persist on the workflow document. ``workflow_scope`` + (``'personal'`` or ``'group'``) lets the rules sent with the request be checked against + where the workflow lives; stored rules that were not sent are carried over unchecked, + as their task references already are. """ workflow_data = workflow_data if isinstance(workflow_data, dict) else {} existing_workflow = existing_workflow if isinstance(existing_workflow, dict) else {} existing_config = resolve_workflow_alert_config(existing_workflow) + if workflow_scope not in WORKFLOW_ALERT_WORKFLOW_SCOPES: + workflow_scope = None alert_priority = _normalize_legacy_alert_priority( workflow_data.get('alert_priority', existing_config.get('alert_priority', 'none')) ) if 'alert_rules' in workflow_data: - alert_rules = normalize_alert_rules(workflow_data.get('alert_rules'), task_ids=task_ids) + alert_rules = normalize_alert_rules( + workflow_data.get('alert_rules'), + task_ids=task_ids, + workflow_scope=workflow_scope, + ) else: alert_rules = normalize_alert_rules(existing_config.get('alert_rules'), task_ids=None) @@ -935,6 +1044,10 @@ def _build_match(rule, evaluation, source='rule'): 'rule_name': rule.get('name'), 'severity': severity, 'delivery': rule.get('delivery') or 'default', + 'require_acknowledgment': normalize_alert_flag(rule.get('require_acknowledgment', False)), + 'sound': resolve_alert_option(rule.get('sound'), WORKFLOW_ALERT_SOUND_ORDER), + 'size': resolve_alert_option(rule.get('size'), WORKFLOW_ALERT_SIZE_ORDER), + 'audience': resolve_alert_option(rule.get('audience'), WORKFLOW_ALERT_AUDIENCE_ORDER), 'condition_type': str(condition.get('type') or '').strip().lower(), 'reason': _normalize_reason(evaluation.get('reason')), 'category': evaluation.get('category') or 'alert', @@ -956,6 +1069,10 @@ def _build_decision(should_alert, mode, matches=None, model_evaluation=None, eva 'severity': '', 'category': 'alert', 'delivery': '', + 'require_acknowledgment': False, + 'sound': WORKFLOW_ALERT_DEFAULT_SOUND, + 'size': WORKFLOW_ALERT_DEFAULT_SIZE, + 'audience': WORKFLOW_ALERT_DEFAULT_AUDIENCE, 'mode': mode, 'matched_rules': matches, 'reasons': [match.get('reason') for match in matches if match.get('reason')], @@ -967,11 +1084,25 @@ def _build_decision(should_alert, mode, matches=None, model_evaluation=None, eva winning_match = matches[0] severity = normalize_alert_severity(winning_match.get('severity')) + # Severity and category follow the winning rule, but a pop-up option any matched rule asked + # for is never quietened by a louder rule that did not ask for it. + require_acknowledgment = any(normalize_alert_flag(match.get('require_acknowledgment')) for match in matches) + sound = strongest_alert_option((match.get('sound') for match in matches), WORKFLOW_ALERT_SOUND_ORDER) + size = strongest_alert_option((match.get('size') for match in matches), WORKFLOW_ALERT_SIZE_ORDER) + audience = strongest_alert_option((match.get('audience') for match in matches), WORKFLOW_ALERT_AUDIENCE_ORDER) + delivery = resolve_alert_delivery(severity, winning_match.get('delivery')) + if alert_options_need_popup(require_acknowledgment, sound, size): + delivery = 'popup' + return { 'should_alert': True, 'severity': severity, 'category': winning_match.get('category') or 'alert', - 'delivery': resolve_alert_delivery(severity, winning_match.get('delivery')), + 'delivery': delivery, + 'require_acknowledgment': require_acknowledgment, + 'sound': sound, + 'size': size, + 'audience': audience, 'mode': mode, 'winning_rule_id': winning_match.get('rule_id'), 'winning_rule_name': winning_match.get('rule_name'), @@ -982,6 +1113,24 @@ def _build_decision(should_alert, mode, matches=None, model_evaluation=None, eva } +def _rule_strengthens_alert_options(rule, matches): + """Return True when ``rule`` asks for a pop-up option stronger than ``matches`` already give.""" + if ( + normalize_alert_flag(rule.get('require_acknowledgment', False)) + and not any(normalize_alert_flag(match.get('require_acknowledgment')) for match in matches) + ): + return True + for key, order in ( + ('sound', WORKFLOW_ALERT_SOUND_ORDER), + ('size', WORKFLOW_ALERT_SIZE_ORDER), + ('audience', WORKFLOW_ALERT_AUDIENCE_ORDER), + ): + current = strongest_alert_option((match.get(key) for match in matches), order) + if order.index(resolve_alert_option(rule.get(key), order)) > order.index(current): + return True + return False + + def evaluate_workflow_alert_rules(workflow, facts, model_evaluator=None): """Decide whether a finished workflow run should raise an alert, and how loudly. @@ -1049,11 +1198,14 @@ def evaluate_workflow_alert_rules(workflow, facts, model_evaluator=None): (get_alert_severity_rank(match.get('severity')) for match in matches), default=-1, ) - # A model call cannot change the outcome when a deterministic rule already - # matched at or above the severity of every remaining model-evaluated rule. + # A model call cannot change the outcome when a deterministic rule already matched at or + # above a model-evaluated rule's severity and the matches already carry every pop-up + # option that rule asks for. Options merge across every matched rule, so a lower-severity + # rule asking for acknowledgment, a louder sound, a larger size or the group still counts. pending_model_rules = [ rule for rule in model_rules if get_alert_severity_rank(rule.get('severity')) > best_rank + or _rule_strengthens_alert_options(rule, matches) ] model_evaluation_state = { diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index 0fbc5e8da..5d9ce7060 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -5650,6 +5650,10 @@ def _record_workflow_alert_decision(workflow, run_record, decision): 'severity': decision.get('severity') or '', 'category': decision.get('category') or '', 'delivery': decision.get('delivery') or '', + 'require_acknowledgment': bool(decision.get('require_acknowledgment')), + 'sound': decision.get('sound') or 'off', + 'size': decision.get('size') or 'small', + 'audience': decision.get('audience') or 'owner', 'mode': decision.get('mode') or '', 'summary': summarize_alert_decision(decision), 'matched_rules': [ @@ -5779,6 +5783,10 @@ def _create_workflow_priority_alert(workflow, run_record, conversation, executio 'priority': priority, 'category': decision.get('category') or 'alert', 'delivery': decision.get('delivery') or 'popup', + 'require_acknowledgment': bool(decision.get('require_acknowledgment')), + 'sound': decision.get('sound') or 'off', + 'size': decision.get('size') or 'small', + 'audience': 'owner', 'alert_mode': decision.get('mode') or 'rules', 'matched_rules': [ { @@ -5813,6 +5821,23 @@ def _create_workflow_priority_alert(workflow, run_record, conversation, executio if execution_result.get('agent_display_name'): metadata['agent_display_name'] = execution_result.get('agent_display_name') + # A rule can alert everyone in a group workflow's group. That raises one shared, + # group-scoped alert instead of the owner's personal one; members other than the owner + # read a reduced version of it (see project_workflow_alert_for_reader). + team_group_id = '' + if decision.get('audience') == 'group': + if _get_workflow_scope(workflow) == 'group': + team_group_id = _get_workflow_group_id(workflow) + else: + log_event( + '[WORKFLOW_RUNNER] Group audience ignored for a workflow outside a group.', + extra={'workflow_id': workflow_id, 'run_id': metadata['run_id']}, + level=logging.WARNING, + ) + if team_group_id: + metadata['audience'] = 'group' + metadata['owner_user_id'] = user_id + return create_workflow_priority_notification( user_id=user_id, workflow_id=workflow_id, @@ -5823,6 +5848,7 @@ def _create_workflow_priority_alert(workflow, run_record, conversation, executio link_url=primary_target.get('link_url') if primary_target else '', link_context=primary_target.get('link_context') if primary_target else {}, metadata=metadata, + group_id=team_group_id or None, ) except Exception as exc: log_event( diff --git a/application/single_app/route_backend_notifications.py b/application/single_app/route_backend_notifications.py index 03f09cfc3..c2e02b4d9 100644 --- a/application/single_app/route_backend_notifications.py +++ b/application/single_app/route_backend_notifications.py @@ -178,14 +178,21 @@ def api_get_chat_completion_audio_status(): @user_required def api_get_workflow_alert_notifications(): """ - Get unread workflow alert notifications for the current user. + Get the workflow alerts that should pop up for the current user. + + Unread pop-up alerts, plus every alert that must be acknowledged and hasn't been, + whatever its age. Includes team alerts from the user's groups. Query Parameters: - limit (int): Most alerts to return, 1-10 (default: 5) - since_hours (int): Only alerts created within this many hours, 1-1440 (optional). + limit (int): Most alerts per bounded read, 1-10 (default: 5) + since_hours (int): Only unread alerts created within this many hours, 1-1440 (optional). The V2 interface asks for 24 so older alerts stay in the bell; the classic interface leaves it out. With since_hours, a failed read answers 500 rather - than an empty list. + than an empty list. Alerts that must be acknowledged ignore it. + + Returns: + notifications (list), complete (bool: no bounded read came back full) and + sounds_enabled (bool: the administrator allows workflow alert sounds). """ try: since_hours = parse_workflow_alert_since_hours(request.args.get('since_hours')) @@ -202,10 +209,10 @@ def api_get_workflow_alert_notifications(): if limit < 1 or limit > 10: limit = 5 - # V2 is the caller that asks for since_hours, and it reads a short list as every - # unread pop-up alert there is. A failed read therefore answers 500 rather than an - # empty list it would take at its word; classic keeps the empty list it always had. - notifications = get_unread_workflow_priority_notifications( + # V2 is the caller that asks for since_hours, and it reads the list as every pop-up + # alert there is unless `complete` says otherwise. A failed read therefore answers 500 + # rather than an empty list it would take at its word; classic keeps the empty list. + popups = get_workflow_alert_popups( user_id, limit=limit, since_hours=since_hours, @@ -213,7 +220,9 @@ def api_get_workflow_alert_notifications(): ) return jsonify({ 'success': True, - 'notifications': notifications, + 'notifications': popups['notifications'], + 'complete': popups['complete'], + 'sounds_enabled': bool(get_settings().get('enable_workflow_alert_sounds', True)), }) except Exception as e: debug_print(f"Error fetching workflow alert notifications: {e}") @@ -222,6 +231,62 @@ def api_get_workflow_alert_notifications(): 'notifications': [], }), 500 + @bp.route("/api/notifications//acknowledge", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_acknowledge_workflow_alert(notification_id): + """ + Acknowledge a workflow alert that must be acknowledged. + + One acknowledgment clears the alert for everyone who receives it: its owner, or every + member of a team alert's group. Only those recipients can acknowledge it; anyone else + is answered 404. Also marks the alert read for the caller. + """ + user_id = None + try: + user_id = get_current_user_id() + user_info = get_current_user_info() or {} + display_name = user_info.get('displayName') or user_info.get('email') or '' + status, notification = acknowledge_workflow_alert( + notification_id, + user_id, + display_name=display_name, + ) + if status == 'not_found': + return jsonify({ + 'success': False, + 'error': 'Alert not found.', + }), 404 + if status == 'not_required': + return jsonify({ + 'success': False, + 'error': 'This alert does not need acknowledgment.', + }), 400 + + bump_conversation_cache_version(user_id, reason="workflow_alert_acknowledged") + return jsonify({ + 'success': True, + 'notification_id': notification.get('id'), + 'acknowledged_at': notification.get('acknowledged_at'), + 'acknowledged_by_name': notification.get('acknowledged_by_name'), + 'already_acknowledged': status == 'already_acknowledged', + }) + except Exception as e: + log_event( + "[NOTIFICATIONS] Workflow alert acknowledgment failed.", + extra={ + "user_id": user_id, + "error_type": type(e).__name__, + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({ + 'success': False, + 'error': 'Unable to acknowledge the alert.', + }), 500 + @bp.route("/api/notifications//read", methods=["POST"]) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index a1510a747..424079ae1 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -715,6 +715,9 @@ def v2_bootstrap(): "enable_chat_workflow_results": is_chat_workflow_results_enabled_for_user( settings, user_roles=current_user_roles ), + "enable_workflow_alert_sounds": ( + settings.get("enable_workflow_alert_sounds", True) is not False + ), "enable_source_review": is_source_review_enabled_for_user( settings, user_id, diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index e45089347..0d1def4aa 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -910,6 +910,8 @@ def admin_settings(): settings['enable_workflow_ai_assistant'] = True if 'enable_chat_workflow_results' not in settings: settings['enable_chat_workflow_results'] = False + if 'enable_workflow_alert_sounds' not in settings: + settings['enable_workflow_alert_sounds'] = True if 'allow_group_workflows' not in settings: settings['allow_group_workflows'] = False if 'require_group_assignment_for_group_workflows' not in settings: @@ -2609,6 +2611,7 @@ def is_valid_url(url): 'require_member_of_workflow_user': require_member_of_workflow_user, 'enable_workflow_ai_assistant': form_data.get('enable_workflow_ai_assistant') == 'on', 'enable_chat_workflow_results': form_data.get('enable_chat_workflow_results') == 'on', + 'enable_workflow_alert_sounds': form_data.get('enable_workflow_alert_sounds') == 'on', 'allow_group_workflows': form_data.get('allow_group_workflows') == 'on', 'require_group_assignment_for_group_workflows': form_data.get('require_group_assignment_for_group_workflows') == 'on', 'group_workflow_allowed_group_ids': group_workflow_allowed_group_ids, diff --git a/application/single_app/static/audio/workflow-alerts/alarm.wav b/application/single_app/static/audio/workflow-alerts/alarm.wav new file mode 100644 index 000000000..96c1a90b0 Binary files /dev/null and b/application/single_app/static/audio/workflow-alerts/alarm.wav differ diff --git a/application/single_app/static/audio/workflow-alerts/chime.wav b/application/single_app/static/audio/workflow-alerts/chime.wav new file mode 100644 index 000000000..412ac9b43 Binary files /dev/null and b/application/single_app/static/audio/workflow-alerts/chime.wav differ diff --git a/application/single_app/static/audio/workflow-alerts/urgent.wav b/application/single_app/static/audio/workflow-alerts/urgent.wav new file mode 100644 index 000000000..c91bacda7 Binary files /dev/null and b/application/single_app/static/audio/workflow-alerts/urgent.wav differ diff --git a/application/single_app/static/js/notifications.js b/application/single_app/static/js/notifications.js index 69f917335..a8df0aab5 100644 --- a/application/single_app/static/js/notifications.js +++ b/application/single_app/static/js/notifications.js @@ -28,6 +28,7 @@ let activeWorkflowAlert = null; let activeWorkflowAlertTargets = []; let isLoadingWorkflowAlerts = false; + let hasLoadedWorkflowAlerts = false; let isLoadingChatCompletionEvents = false; const shownWorkflowAlertsStorageKey = 'simplechat-shown-workflow-alerts'; const workflowAlertModalEl = document.getElementById('workflowAlertModal'); @@ -48,6 +49,21 @@ const workflowAlertLinks = document.getElementById('workflow-alert-links'); const workflowAlertMarkReadBtn = document.getElementById('workflow-alert-mark-read-btn'); const workflowAlertDismissBtn = document.getElementById('workflow-alert-dismiss-btn'); + const workflowAlertAcknowledgeBtn = document.getElementById('workflow-alert-acknowledge-btn'); + const workflowAlertModalDialog = workflowAlertModalEl?.querySelector('.workflow-alert-modal-dialog') || null; + const workflowAlertAckBanner = document.getElementById('workflowAlertAckBanner'); + const workflowAlertAckBannerCount = document.getElementById('workflow-alert-ack-banner-count'); + const workflowAlertAckBannerReviewBtn = document.getElementById('workflow-alert-ack-banner-review-btn'); + const workflowAlertEnableSoundBtn = document.getElementById('workflow-alert-enable-sound-btn'); + const workflowAlertModalEnableSoundBtn = document.getElementById('workflow-alert-modal-enable-sound-btn'); + const pendingMustAcknowledgeAlerts = new Map(); + // Alerts that need acknowledgment and were already shown on this page. Closing one keeps it in + // the banner rather than popping it up on every poll; a reload shows it again. + const shownMustAcknowledgeIds = new Set(); + let suppressAckBannerOnce = false; + const workflowAlertBroadcastChannel = typeof BroadcastChannel !== 'undefined' + ? new BroadcastChannel('simplechat.workflowAlerts') + : null; /** * Get randomized poll interval @@ -218,6 +234,112 @@ return delivery === 'notify_only' ? 'notify_only' : 'popup'; } + function isMustAcknowledgeWorkflowAlert(notification) { + const metadata = notification?.metadata || {}; + return ( + (notification?.require_acknowledgment === true || metadata.require_acknowledgment === true) + && notification?.acknowledged !== true + ); + } + + function getWorkflowAlertSoundMode(notification) { + const metadata = notification?.metadata || {}; + const sound = String(notification?.sound || metadata.sound || 'off').trim().toLowerCase(); + return ['once', 'repeat'].includes(sound) ? sound : 'off'; + } + + function getWorkflowAlertSize(notification) { + const metadata = notification?.metadata || {}; + const size = String(notification?.size || metadata.size || 'small').trim().toLowerCase(); + return ['small', 'medium', 'large'].includes(size) ? size : 'small'; + } + + function getWorkflowAlertAudience(notification) { + const metadata = notification?.metadata || {}; + const audience = String(notification?.audience || metadata.audience || 'owner').trim().toLowerCase(); + return audience === 'group' ? 'group' : 'owner'; + } + + function formatAcknowledgedText(notification) { + const acknowledgedByName = normalizeWorkflowAlertText(notification?.acknowledged_by_name || ''); + const acknowledgedAt = notification?.acknowledged_at + ? formatRelativeTime(notification.acknowledged_at) + : ''; + if (acknowledgedByName && acknowledgedAt) { + return `Acknowledged by ${acknowledgedByName} ${acknowledgedAt}`; + } + if (acknowledgedByName) { + return `Acknowledged by ${acknowledgedByName}`; + } + if (acknowledgedAt) { + return `Acknowledged ${acknowledgedAt}`; + } + return 'Acknowledged'; + } + + function getWorkflowAlertSoundManager() { + return window.simpleChatWorkflowAlertSound || null; + } + + function updateWorkflowAlertSoundGate(soundsEnabled) { + const soundManager = getWorkflowAlertSoundManager(); + if (soundManager?.setAdminEnabled) { + soundManager.setAdminEnabled(soundsEnabled === true); + } + } + + function updateWorkflowAlertAckBanner() { + const soundBlocked = getWorkflowAlertSoundManager()?.blocked === true; + if (workflowAlertModalEnableSoundBtn) { + workflowAlertModalEnableSoundBtn.classList.toggle('d-none', !soundBlocked); + } + if (!workflowAlertAckBanner || !workflowAlertAckBannerCount) { + return; + } + + const count = pendingMustAcknowledgeAlerts.size; + if (count <= 0) { + workflowAlertAckBanner.classList.add('d-none'); + return; + } + + workflowAlertAckBannerCount.textContent = `${count} workflow alert${count === 1 ? '' : 's'} need acknowledgment`; + workflowAlertAckBanner.classList.toggle('d-none', Boolean(activeWorkflowAlert)); + if (workflowAlertEnableSoundBtn) { + workflowAlertEnableSoundBtn.classList.toggle('d-none', !soundBlocked); + } + } + + function showWorkflowAlertAckBanner() { + updateWorkflowAlertAckBanner(); + } + + function broadcastWorkflowAlertAcknowledgment(notificationId) { + if (!notificationId) { + return; + } + + workflowAlertBroadcastChannel?.postMessage({ + type: 'acknowledged', + ids: [notificationId], + }); + } + + function playWorkflowAlertSound(notification, options = {}) { + const soundManager = getWorkflowAlertSoundManager(); + if (!soundManager?.play) { + return; + } + + soundManager.play(notification, { + mode: options.mode || getWorkflowAlertSoundMode(notification), + }); + } + + function stopWorkflowAlertSound(notificationId) { + getWorkflowAlertSoundManager()?.stop?.(notificationId); + } + function formatWorkflowTriggeredTime(isoString) { if (!isoString) { return 'Triggered: --:--:--'; @@ -455,8 +577,26 @@ if (metadata.runner_type) { metaParts.push(`Runner: ${String(metadata.runner_type).trim()}`); } + if (getWorkflowAlertAudience(notification) === 'group') { + metaParts.push('Sent to everyone in the group'); + } metaParts.push(formatRelativeTime(notification.created_at)); + if (workflowAlertModalDialog) { + workflowAlertModalDialog.classList.remove( + 'modal-lg', + 'modal-fullscreen', + 'workflow-alert-size-medium', + 'workflow-alert-size-large' + ); + const size = getWorkflowAlertSize(notification); + if (size === 'medium') { + workflowAlertModalDialog.classList.add('modal-lg', 'workflow-alert-size-medium'); + } else if (size === 'large') { + workflowAlertModalDialog.classList.add('modal-fullscreen', 'workflow-alert-size-large'); + } + } + if (workflowAlertModalContent) { workflowAlertModalContent.dataset.priority = priority || 'medium'; workflowAlertModalContent.dataset.category = getWorkflowAlertCategory(notification); @@ -522,10 +662,85 @@ `).join(''); } } + + const mustAcknowledge = isMustAcknowledgeWorkflowAlert(notification); + if (workflowAlertDismissBtn) { + workflowAlertDismissBtn.classList.toggle('d-none', mustAcknowledge); + } + if (workflowAlertMarkReadBtn) { + workflowAlertMarkReadBtn.classList.toggle('d-none', mustAcknowledge); + } + if (workflowAlertAcknowledgeBtn) { + workflowAlertAcknowledgeBtn.classList.toggle('d-none', !mustAcknowledge); + } + } + + function retireWorkflowAlerts(notificationIds) { + const ids = new Set((notificationIds || []).map(id => String(id || '').trim()).filter(Boolean)); + if (!ids.size) { + return; + } + + let shouldHideActiveModal = false; + ids.forEach(notificationId => { + pendingMustAcknowledgeAlerts.delete(notificationId); + shownMustAcknowledgeIds.delete(notificationId); + stopWorkflowAlertSound(notificationId); + if (activeWorkflowAlert?.id === notificationId) { + shouldHideActiveModal = true; + } + }); + + workflowAlertQueue = workflowAlertQueue.filter(notification => !ids.has(notification.id)); + if (shouldHideActiveModal && workflowAlertModal) { + suppressAckBannerOnce = true; + workflowAlertModal.hide(); + } + updateWorkflowAlertAckBanner(); + } + + function retireMissingMustAcknowledgeAlerts(notifications) { + const returnedIds = new Set((notifications || []).map(notification => notification?.id).filter(Boolean)); + const trackedIds = new Set([ + ...pendingMustAcknowledgeAlerts.keys(), + ...workflowAlertQueue + .filter(notification => isMustAcknowledgeWorkflowAlert(notification)) + .map(notification => notification.id), + ]); + if (activeWorkflowAlert && isMustAcknowledgeWorkflowAlert(activeWorkflowAlert)) { + trackedIds.add(activeWorkflowAlert.id); + } + + const missingIds = Array.from(trackedIds).filter(notificationId => !returnedIds.has(notificationId)); + retireWorkflowAlerts(missingIds); + } + + function rememberPendingMustAcknowledgeAlert(notification) { + if (!isMustAcknowledgeWorkflowAlert(notification) || !notification?.id) { + return; + } + + // Every poll returns a pending alert again; its sound starts only when this page first sees it. + const isNewlyTracked = !pendingMustAcknowledgeAlerts.has(notification.id); + pendingMustAcknowledgeAlerts.set(notification.id, notification); + if (isNewlyTracked && getWorkflowAlertSoundMode(notification) !== 'off') { + playWorkflowAlertSound(notification, { + mode: getWorkflowAlertSoundMode(notification), + }); + } + } + + function hasTrackedMustAcknowledgeAlerts() { + return ( + pendingMustAcknowledgeAlerts.size > 0 + || Boolean(activeWorkflowAlert && isMustAcknowledgeWorkflowAlert(activeWorkflowAlert)) + || workflowAlertQueue.some(notification => isMustAcknowledgeWorkflowAlert(notification)) + ); } function enqueueWorkflowAlerts(notifications) { if (!Array.isArray(notifications) || !notifications.length) { + updateWorkflowAlertAckBanner(); return; } @@ -534,7 +749,16 @@ if (!notification?.id) { return; } - if (queuedIds.has(notification.id) || activeWorkflowAlert?.id === notification.id || hasShownWorkflowAlert(notification.id)) { + const mustAcknowledge = isMustAcknowledgeWorkflowAlert(notification); + if (mustAcknowledge) { + rememberPendingMustAcknowledgeAlert(notification); + } + if ( + queuedIds.has(notification.id) + || activeWorkflowAlert?.id === notification.id + || (!mustAcknowledge && hasShownWorkflowAlert(notification.id)) + || (mustAcknowledge && shownMustAcknowledgeIds.has(notification.id)) + ) { return; } // Quiet severities stay in the notification bell instead of interrupting. @@ -547,6 +771,7 @@ }); showNextWorkflowAlert(); + updateWorkflowAlertAckBanner(); } function showNextWorkflowAlert() { @@ -556,8 +781,16 @@ activeWorkflowAlert = workflowAlertQueue.shift(); activeWorkflowAlertTargets = []; - rememberShownWorkflowAlert(activeWorkflowAlert.id); + if (isMustAcknowledgeWorkflowAlert(activeWorkflowAlert)) { + shownMustAcknowledgeIds.add(activeWorkflowAlert.id); + } else { + rememberShownWorkflowAlert(activeWorkflowAlert.id); + if (getWorkflowAlertSoundMode(activeWorkflowAlert) === 'once') { + playWorkflowAlertSound(activeWorkflowAlert, { mode: 'once' }); + } + } populateWorkflowAlertModal(activeWorkflowAlert); + updateWorkflowAlertAckBanner(); workflowAlertModal.show(); } @@ -577,6 +810,13 @@ .then(data => { if (data.success) { consecutivePollFailures = 0; + hasLoadedWorkflowAlerts = true; + updateWorkflowAlertSoundGate(data.sounds_enabled === true); + // A capped answer can leave out an alert that still needs acknowledgment, + // so only a complete one may retire what this page is tracking. + if (data.complete === true) { + retireMissingMustAcknowledgeAlerts(data.notifications || []); + } enqueueWorkflowAlerts(data.notifications || []); } }) @@ -646,7 +886,9 @@ data.chat_completion_audio_updated_at ); const followUpRequests = [loadChatCompletionEvents()]; - if (data.count > 0) { + // Alerts that need acknowledgment can be read and still pending, so they are + // read once per page load and while this page tracks one, not only on a count. + if (data.count > 0 || !hasLoadedWorkflowAlerts || hasTrackedMustAcknowledgeAlerts()) { followUpRequests.push(loadWorkflowAlerts()); } return Promise.all(followUpRequests); @@ -781,6 +1023,13 @@ function renderNotification(notification) { const isUnread = !notification.is_read; const typeConfig = notification.type_config || { icon: 'bi-bell', color: 'secondary' }; + const mustAcknowledge = isMustAcknowledgeWorkflowAlert(notification); + const acknowledged = notification.acknowledged === true; + const acknowledgmentHtml = mustAcknowledge + ? 'Needs acknowledgment' + : acknowledged + ? `
${escapeHtml(formatAcknowledgedText(notification))}
` + : ''; return `
@@ -790,13 +1039,18 @@
-
${escapeHtml(notification.title)}
+
${escapeHtml(notification.title)}${acknowledgmentHtml}
${escapeHtml(notification.message)}
${formatRelativeTime(notification.created_at)}
+ ${mustAcknowledge ? ` + + ` : ''} ${!isUnread ? '' : `
+
+ + + +
+
+
+
+ 0 workflow alerts need acknowledgment +
+
+ + +
+
+
{% endif %} @@ -759,6 +805,7 @@
url_access_max_workflow_urls_per_run: {{ settings.url_access_max_workflow_urls_per_run|default(50, true)|tojson }} }; window.workflowSettings = { - workflow_max_tasks: {{ settings.workflow_max_tasks|default(50, true)|tojson }} + workflow_max_tasks: {{ settings.workflow_max_tasks|default(50, true)|tojson }}, + enable_workflow_alert_sounds: {{ settings.get('enable_workflow_alert_sounds', True)|tojson }} }; window.workflowWorkspaceConfig = { scope: 'group', diff --git a/application/single_app/templates/profile.html b/application/single_app/templates/profile.html index ca1b19510..e5c6859a6 100644 --- a/application/single_app/templates/profile.html +++ b/application/single_app/templates/profile.html @@ -936,6 +936,46 @@
AI Response Completion Audi {% endif %} +
+
+
+ +
+
Workflow Alert Sounds
+

Control whether workflow alerts can play sounds on this device.

+

+ This preference is stored in this browser. Administrators can still turn workflow alert sounds off for everyone. +

+
+
+
+ +
+
+
+ + +
+
+
+ +
+
+ {% set sidebar_toggle_style = user_settings.get('settings', {}).get('sidebarToggleStyle', 'large') %} {% set latest_features_hidden_version = user_settings.get('settings', {}).get('latestFeaturesHiddenVersion') %} {% set latest_features_hidden_for_current_version = latest_features_hidden_version == config['VERSION'] %} diff --git a/application/single_app/templates/workspace.html b/application/single_app/templates/workspace.html index 945ad874e..8bbeda3c6 100644 --- a/application/single_app/templates/workspace.html +++ b/application/single_app/templates/workspace.html @@ -2604,7 +2604,8 @@
void; onRead: () => void; onDismiss: () => void; + onAcknowledge: () => void; }) { const described = describeNotification(notification); const Icon = KIND_ICONS[described.kind]; @@ -91,6 +94,12 @@ function NotificationRow({ const unread = !notification.is_read; const created = notification.created_at ? new Date(notification.created_at) : null; const createdValid = created !== null && !Number.isNaN(created.getTime()); + const needsAcknowledgment = notification.notification_type === 'workflow_priority_alert' + && notification.require_acknowledgment === true + && notification.acknowledged !== true; + const acknowledged = notification.notification_type === 'workflow_priority_alert' + && notification.require_acknowledgment === true + && notification.acknowledged === true; return (
  • {unread &&
  • @@ -371,6 +437,66 @@ function AlertRuleRow({ rule, position, count, tasks, error, onChange, onMove, o setAcknowledgment(event.target.checked)} /> + Require acknowledgment + +

    + The alert keeps coming back, on every page and device, until someone acknowledges it. +

    +
    + + + {workflowScope === 'group' ? ( + + ) : null} + + set({ enabled: checked })} /> @@ -381,16 +507,20 @@ function AlertRuleRow({ rule, position, count, tasks, error, onChange, onMove, o /** The Alerts section for a workflow the viewer can manage. Read-only viewers get WorkflowAlertSummary. */ export function WorkflowAlertEditor({ workflow, + scope, onChange, }: { workflow: WorkflowDefinition; + scope: WorkflowScope; onChange: (update: (workflow: WorkflowDefinition) => WorkflowDefinition) => void; }) { const titleId = useId(); + const workflowScope = scope.type; + const soundsEnabled = useBootstrapStore((state) => state.data?.features?.enable_workflow_alert_sounds !== false); const config = workflowAlertConfig(workflow); const rules = config.alert_rules.map((rule) => record(rule)); const tasks = workflow.tasks.map((task) => ({ id: task.id, name: task.name })); - const errors = new Map(workflowAlertRuleErrors(config.alert_rules, tasks.map((task) => task.id)) + const errors = new Map(workflowAlertRuleErrors(config.alert_rules, tasks.map((task) => task.id), workflowScope) .map((error) => [error.position, error.message])); // A stored value the server refuses stays visible, so the author can see and replace it. const mode = storedChoice(workflow.alert_mode, MODE_OPTIONS, config.alert_mode); @@ -468,7 +598,7 @@ export function WorkflowAlertEditor({ {rules.map((rule, index) => ( editRules((current) => current.map((item, itemIndex) => itemIndex === index ? next : item))} onMove={(direction) => moveRule(index, direction)} onRemove={() => editRules((current) => current.filter((_, itemIndex) => itemIndex !== index))} /> diff --git a/application/v2_ui/src/components/workflows/WorkflowAlertSummary.tsx b/application/v2_ui/src/components/workflows/WorkflowAlertSummary.tsx index cbf7a667b..9a674a045 100644 --- a/application/v2_ui/src/components/workflows/WorkflowAlertSummary.tsx +++ b/application/v2_ui/src/components/workflows/WorkflowAlertSummary.tsx @@ -41,6 +41,12 @@ export function WorkflowAlertSummary({ workflow }: { workflow: WorkflowDefinitio
    Alert rules
    {summary.ruleCount === 1 ? '1 rule' : `${summary.ruleCount} rules`}
    + {summary.optionSummary.length ? ( +
    +
    Pop-up options
    +
    {summary.optionSummary.join(' · ')}
    +
    + ) : null} ); diff --git a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx index c2556d4c8..3a9edef1c 100644 --- a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx +++ b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx @@ -975,7 +975,8 @@ export function WorkflowEditorDialog({ {/* Rules watch tasks by ID, so alerts follow the tasks they can refer to. */} {options.can_manage && !readOnly ? ( - setWorkflow((current) => update(current))} /> + setWorkflow((current) => update(current))} /> ) : ( diff --git a/application/v2_ui/src/dev/AlertLabPage.tsx b/application/v2_ui/src/dev/AlertLabPage.tsx index f9d6a799e..23f74264e 100644 --- a/application/v2_ui/src/dev/AlertLabPage.tsx +++ b/application/v2_ui/src/dev/AlertLabPage.tsx @@ -161,6 +161,13 @@ export function AlertLabPage() { note(`Dismissed: ${ids.length} alert${ids.length === 1 ? '' : 's'}.`); return ids; }, + acknowledge: async (ids) => { + if (failRef.current) { + return refuse('acknowledge'); + } + note(`Acknowledged: ${ids.length} alert${ids.length === 1 ? '' : 's'}.`); + return ids; + }, open: async (target) => { const where = describeTarget(target); note(`Would open ${where}.`); diff --git a/application/v2_ui/src/dev/alertLabSamples.ts b/application/v2_ui/src/dev/alertLabSamples.ts index 3f5c1786c..ed2c3baa9 100644 --- a/application/v2_ui/src/dev/alertLabSamples.ts +++ b/application/v2_ui/src/dev/alertLabSamples.ts @@ -21,7 +21,14 @@ export type AlertLabScenarioId = | 'group' | 'dialog' | 'notify-only' - | 'old'; + | 'old' + | 'must-ack-small' + | 'must-ack-medium' + | 'must-ack-large' + | 'repeat-sound' + | 'blocked-audio' + | 'team-member' + | 'acknowledged-elsewhere'; export interface AlertLabScenario { id: AlertLabScenarioId; @@ -42,6 +49,13 @@ export function alertLabScenarios(): AlertLabScenario[] { { id: 'dialog', label: 'While a dialog is open', expect: 'Waits until the dialog closes.' }, { id: 'notify-only', label: 'Notify-only', expect: 'Never pops up; it waits in the bell.' }, { id: 'old', label: 'Older than 24 hours', expect: 'Never pops up; it waits in the bell.' }, + { id: 'must-ack-small', label: 'Must-ack small', expect: 'Persistent notice with Acknowledge instead of Dismiss.' }, + { id: 'must-ack-medium', label: 'Must-ack medium', expect: 'Card opens directly as a large dialog.' }, + { id: 'must-ack-large', label: 'Must-ack large', expect: 'Full-screen takeover that minimizes on Escape.' }, + { id: 'repeat-sound', label: 'Repeating sound', expect: 'Alarm repeats until acknowledged.' }, + { id: 'blocked-audio', label: 'Blocked audio', expect: 'Shows Enable sound if the browser blocks playback.' }, + { id: 'team-member', label: 'Team alert as member', expect: 'Lean member content and group audience copy.' }, + { id: 'acknowledged-elsewhere', label: 'Acknowledged elsewhere', expect: 'Shows the acknowledged state in sample data.' }, ]; } @@ -75,6 +89,13 @@ interface AlertSampleInput { links?: LinkSample[]; minutesAgo: number; delivery?: 'popup' | 'notify_only'; + requireAcknowledgment?: boolean; + sound?: 'off' | 'once' | 'repeat'; + size?: 'small' | 'medium' | 'large'; + audience?: 'owner' | 'group'; + acknowledged?: boolean; + acknowledgedByName?: string; + contentScope?: 'full' | 'member'; /** Leave out the fields an alert written before workflow scope existed would lack. */ legacy?: boolean; } @@ -114,6 +135,10 @@ function sample(stamp: string, index: number, now: number, input: AlertSampleInp priority: input.priority, category: input.category, delivery, + require_acknowledgment: input.requireAcknowledgment === true, + sound: input.sound ?? 'off', + size: input.size ?? 'small', + audience: input.audience ?? 'owner', alert_mode: 'rules', matched_rules: (input.rules ?? []).map((rule, ruleIndex) => ({ rule_id: `lab-rule-${ruleIndex}`, @@ -155,6 +180,14 @@ function sample(stamp: string, index: number, now: number, input: AlertSampleInp created_at: new Date(now - input.minutesAgo * 60_000).toISOString(), is_read: false, is_dismissed: false, + require_acknowledgment: input.requireAcknowledgment === true, + sound: input.sound ?? 'off', + size: input.size ?? 'small', + audience: input.audience ?? 'owner', + acknowledged: input.acknowledged === true, + acknowledged_at: input.acknowledged ? new Date(now - 30_000).toISOString() : null, + acknowledged_by_name: input.acknowledgedByName ?? null, + content_scope: input.contentScope ?? 'full', link_url: links[0]?.url ?? '', link_context: links[0]?.context ?? {}, metadata, @@ -389,6 +422,79 @@ export function alertLabScenarioAlerts(id: AlertLabScenarioId, stamp: string, no summary: 'This alert is 26 hours old, so it waits in the bell.', minutesAgo: 26 * 60, })]; + case 'must-ack-small': + return [sample(stamp, 0, now, { + workflow: wf.security, + priority: 'high', + category: 'alert', + title: 'Security feed needs an owner', + summary: 'The advisory feed reported a production-impacting vulnerability.', + minutesAgo: 90 * 24, + requireAcknowledgment: true, + sound: 'once', + })]; + case 'must-ack-medium': + return [sample(stamp, 0, now, { + workflow: wf.build, + priority: 'high', + category: 'failure', + title: 'Release validation failed', + summary: 'The release validation workflow failed and needs ownership.', + error: 'Smoke tests failed in region eastus2.', + minutesAgo: 90 * 24, + requireAcknowledgment: true, + size: 'medium', + sound: 'once', + })]; + case 'must-ack-large': + return [sample(stamp, 0, now, { + workflow: wf.security, + priority: 'critical', + category: 'alert', + title: 'Critical dependency alert', + summary: 'A production dependency has a critical advisory and needs immediate acknowledgment.', + minutesAgo: 90 * 24, + requireAcknowledgment: true, + size: 'large', + sound: 'repeat', + })]; + case 'repeat-sound': + case 'blocked-audio': + return [sample(stamp, 0, now, { + workflow: wf.security, + priority: 'critical', + category: 'alert', + title: 'Operations alarm', + summary: 'Repeats its sound until someone acknowledges it.', + minutesAgo: 1, + requireAcknowledgment: true, + sound: 'repeat', + })]; + case 'team-member': + return [sample(stamp, 0, now, { + workflow: wf.build, + priority: 'high', + category: 'alert', + title: 'Group workflow needs review', + summary: 'Build watcher matched the failed-runs rule.', + minutesAgo: 1, + requireAcknowledgment: true, + audience: 'group', + contentScope: 'member', + })]; + case 'acknowledged-elsewhere': + return [sample(stamp, 0, now, { + workflow: wf.build, + priority: 'medium', + category: 'alert', + title: 'Already acknowledged alert', + summary: 'Morgan acknowledged this in another tab.', + minutesAgo: 1, + requireAcknowledgment: true, + audience: 'group', + acknowledged: true, + acknowledgedByName: 'Morgan Lee', + })]; case 'dialog': default: return [priorityAlert(stamp, 0, now, 'medium', 'alert', 1)]; diff --git a/application/v2_ui/src/lib/notifications.ts b/application/v2_ui/src/lib/notifications.ts index 44162929a..9c5b74303 100644 --- a/application/v2_ui/src/lib/notifications.ts +++ b/application/v2_ui/src/lib/notifications.ts @@ -37,6 +37,22 @@ export interface AppNotification { priority?: string; /** Workflow alerts only; `failure` marks a run that errored. */ category?: string; + /** Workflow alerts only. Kept open until someone acknowledges it. */ + require_acknowledgment?: boolean; + /** Workflow alerts only. */ + sound?: string; + /** Workflow alerts only. */ + size?: string; + /** Workflow alerts only. */ + audience?: string; + /** Workflow alerts only. */ + acknowledged?: boolean; + /** Workflow alerts only. */ + acknowledged_at?: string | null; + /** Workflow alerts only. */ + acknowledged_by_name?: string | null; + /** Workflow alerts only. */ + content_scope?: string; } export interface NotificationPage { @@ -88,6 +104,14 @@ export function normalizeNotification(raw: unknown): AppNotification | null { }, priority: text(raw.priority) || undefined, category: text(raw.category) || undefined, + require_acknowledgment: raw.require_acknowledgment === true, + sound: text(raw.sound) || undefined, + size: text(raw.size) || undefined, + audience: text(raw.audience) || undefined, + acknowledged: raw.acknowledged === true, + acknowledged_at: text(raw.acknowledged_at) || null, + acknowledged_by_name: text(raw.acknowledged_by_name) || null, + content_scope: text(raw.content_scope) || undefined, }; } @@ -139,6 +163,9 @@ export const dismissNotification = (notificationId: string) => export const markAllNotificationsRead = () => api.post('/api/notifications/mark-all-read'); +export const acknowledgeNotification = (notificationId: string) => + api.post(`/api/notifications/${encodeURIComponent(notificationId)}/acknowledge`); + /** How the badge reads a count: the number itself, or `9+` at and above the server's cap. */ export function formatNotificationCount(count: number): string { return count >= NOTIFICATION_COUNT_CAP ? '9+' : String(count); diff --git a/application/v2_ui/src/lib/types.ts b/application/v2_ui/src/lib/types.ts index d56b8ee11..348f83995 100644 --- a/application/v2_ui/src/lib/types.ts +++ b/application/v2_ui/src/lib/types.ts @@ -1053,6 +1053,10 @@ export interface PublicWorkspaceLabels { max_length: number; } +export interface BootstrapFeatures extends Record { + enable_workflow_alert_sounds?: boolean; +} + export interface BootstrapPayload { version: string; user: { @@ -1092,7 +1096,7 @@ export interface BootstrapPayload { custom_pages: NavGroup; external_links: NavGroup; }; - features: Record; + features: BootstrapFeatures; /** * What the deployment can do, where the answer is not an `enable_*` settings flag. * diff --git a/application/v2_ui/src/lib/useWorkflowAlertRuntime.ts b/application/v2_ui/src/lib/useWorkflowAlertRuntime.ts index 646031453..211b60766 100644 --- a/application/v2_ui/src/lib/useWorkflowAlertRuntime.ts +++ b/application/v2_ui/src/lib/useWorkflowAlertRuntime.ts @@ -26,8 +26,10 @@ import { useEffect } from 'react'; import { NOTIFICATION_COUNT_CAP } from './notifications'; -import { fetchWorkflowAlerts, workflowAlertEntryIds } from './workflowAlertNotices'; +import { fetchWorkflowAlerts } from './workflowAlertNotices'; import { lastWorkflowAlertActionAt } from './workflowAlertActions'; +import { workflowAlertMonitorEnabled } from './workflowAlertDevicePreferences'; +import { setWorkflowAlertSoundsEnabled } from './workflowAlertSound'; import { subscribeNotificationCount, useNotificationStore, @@ -45,6 +47,7 @@ const RETURN_FETCH_GAP_MS = 2_000; const RETURN_HOLD_MS = 3_000; /** How often a waiting alert checks whether the page is free to show it. */ const GATE_INTERVAL_MS = 700; +const MUST_ACK_FETCH_MS = 20_000; const DIALOG_SELECTOR = '[role="dialog"], [role="alertdialog"], dialog[open]'; @@ -109,6 +112,7 @@ export function useWorkflowAlertRuntime(ready: boolean): void { // Bumped when a hold starts, so only a read that leaves after it can lift it. let holdGeneration = 0; let gateTimer: ReturnType | null = null; + let mustAckTimer: ReturnType | null = null; let gateQueued = false; const pending = (): boolean => { @@ -116,6 +120,12 @@ export function useWorkflowAlertRuntime(ready: boolean): void { return state.queue.length > 0 || state.entries.length > 0; }; + const pendingMustAck = (): boolean => { + const state = store.getState(); + return [...state.queue, ...state.entries.flatMap((entry) => entry.alerts)] + .some((alert) => alert.requireAcknowledgment && !alert.acknowledged); + }; + const updateGate = (): void => { if (disposed) { return; @@ -162,10 +172,11 @@ export function useWorkflowAlertRuntime(ready: boolean): void { inFlight = controller; lastFetchAt = Date.now(); void fetchWorkflowAlerts(controller.signal) - .then(({ alerts, complete }) => { + .then(({ alerts, complete, soundsEnabled }) => { if (disposed || feedPaused || controller.signal.aborted) { return; } + setWorkflowAlertSoundsEnabled(soundsEnabled); const state = store.getState(); // The gate is only watched while something waits, so a dialog opened // while nothing did is noticed here, before these alerts can be shown. @@ -200,6 +211,10 @@ export function useWorkflowAlertRuntime(ready: boolean): void { if (feedPaused) { return; } + if (change.reason === 'poll' && workflowAlertMonitorEnabled()) { + fetchNow(); + return; + } if (change.count <= 0) { // Never a reason on its own to retire an alert: see the header. A complete // answer retires what it no longer lists, and a failed read keeps it. A read @@ -218,7 +233,8 @@ export function useWorkflowAlertRuntime(ready: boolean): void { } const now = Date.now(); const fell = change.previousCount !== null && change.count < change.previousCount; - if (change.rose + if ((change.reason === 'poll' && workflowAlertMonitorEnabled()) + || change.rose || (change.reason === 'poll' && change.count >= NOTIFICATION_COUNT_CAP) || (change.reason === 'poll' && now - lastFetchAt >= SAFETY_FETCH_MS) || (fell && pending() && now - lastWorkflowAlertActionAt() > OWN_ACTION_GRACE_MS)) { @@ -239,24 +255,30 @@ export function useWorkflowAlertRuntime(ready: boolean): void { }; // The bell's store is where this tab reads and dismisses notices. An alert handled - // there is done with here. + // there is done with here, except one that needs acknowledgment: reading or dismissing + // it doesn't settle it, so only an acknowledgment the bell shows retires it. const unsubscribeBell = useNotificationStore.subscribe((state, previous) => { if (state.items === previous.items) { return; } const alerts = store.getState(); - const tracked = new Set([ - ...alerts.queue.map((alert) => alert.id), - ...alerts.entries.flatMap(workflowAlertEntryIds), - ]); + const tracked = new Map([ + ...alerts.queue, + ...alerts.entries.flatMap((entry) => entry.alerts), + ].map((alert) => [alert.id, alert])); if (!tracked.size) { return; } const items = new Map(state.items.map((item) => [item.id, item])); const handled: string[] = []; - for (const id of tracked) { + const acknowledged: string[] = []; + for (const [id, alert] of tracked) { const item = items.get(id); - if (item?.is_read) { + if (alert.requireAcknowledgment && !alert.acknowledged) { + if (item?.acknowledged) { + acknowledged.push(id); + } + } else if (item?.is_read) { handled.push(id); } else if (!item && state.pendingIds[id] && previous.items.some((candidate) => candidate.id === id)) { handled.push(id); @@ -265,11 +287,20 @@ export function useWorkflowAlertRuntime(ready: boolean): void { if (handled.length) { store.getState().removeAlerts(handled); } + if (acknowledged.length) { + store.getState().retireAcknowledged(acknowledged); + } }); const unsubscribeAlerts = store.subscribe((state, previous) => { if (state.queue !== previous.queue || state.entries !== previous.entries) { queueGate(); + if (pendingMustAck() && mustAckTimer === null) { + mustAckTimer = setInterval(() => fetchNow(), MUST_ACK_FETCH_MS); + } else if (!pendingMustAck() && mustAckTimer !== null) { + clearInterval(mustAckTimer); + mustAckTimer = null; + } } }); const unsubscribeUi = useUiStore.subscribe((state, previous) => { @@ -302,6 +333,9 @@ export function useWorkflowAlertRuntime(ready: boolean): void { if (gateTimer !== null) { clearInterval(gateTimer); } + if (mustAckTimer !== null) { + clearInterval(mustAckTimer); + } observer.disconnect(); document.removeEventListener('visibilitychange', onVisibilityChange); unsubscribeBell(); diff --git a/application/v2_ui/src/lib/workflowAlertActions.ts b/application/v2_ui/src/lib/workflowAlertActions.ts index 79e9b5b31..a76bf17a0 100644 --- a/application/v2_ui/src/lib/workflowAlertActions.ts +++ b/application/v2_ui/src/lib/workflowAlertActions.ts @@ -10,7 +10,7 @@ // Links are followed by the bell's own navigation (notificationNavigation.ts), after the // bell's resolver has checked them, so the card never builds an address of its own. -import { dismissNotification, markNotificationRead } from './notifications'; +import { acknowledgeNotification, dismissNotification, markNotificationRead } from './notifications'; import type { NotificationTarget } from './notificationLinks'; import { openNotificationTarget, type NotificationNavigationContext } from './notificationNavigation'; import { refreshNotificationCount, useNotificationStore } from '../stores/notificationStore'; @@ -21,11 +21,14 @@ export interface WorkflowAlertActions { markRead: (ids: string[]) => Promise; /** Resolves to the ids that are now dismissed. */ dismiss: (ids: string[]) => Promise; + /** Resolves to the ids that are now acknowledged. */ + acknowledge: (ids: string[]) => Promise; /** Follow a resolved link, or an open action's route. */ open: (target: NotificationTarget, context: NotificationNavigationContext) => Promise; } let lastActionAt = 0; +const BROADCAST_CHANNEL = 'simplechat.workflowAlerts'; /** * When the card last changed a notice. The bell count falls after each change, and the @@ -35,6 +38,19 @@ export function lastWorkflowAlertActionAt(): number { return lastActionAt; } +function broadcastAcknowledged(ids: string[]): void { + if (!ids.length || typeof BroadcastChannel === 'undefined') { + return; + } + try { + const channel = new BroadcastChannel(BROADCAST_CHANNEL); + channel.postMessage({ type: 'acknowledged', ids }); + channel.close(); + } catch { + /* Broadcast is best-effort; the server read still resolves state. */ + } +} + async function apply( ids: string[], viaBell: (id: string) => Promise, @@ -81,5 +97,26 @@ export const workflowAlertServerActions: WorkflowAlertActions = { dismissNotification, ids.length > 1 ? 'Those alerts could not all be dismissed.' : 'That alert could not be dismissed.', ), + acknowledge: async (ids) => { + lastActionAt = Date.now(); + let failed = false; + const results = await Promise.all(ids.map(async (id) => { + try { + await acknowledgeNotification(id); + return id; + } catch { + failed = true; + return null; + } + })); + lastActionAt = Date.now(); + void refreshNotificationCount('action'); + const done = results.filter((id): id is string => id !== null); + if (failed) { + toast.error(ids.length > 1 ? 'Those alerts could not all be acknowledged.' : 'That alert could not be acknowledged.'); + } + broadcastAcknowledged(done); + return done; + }, open: openNotificationTarget, }; diff --git a/application/v2_ui/src/lib/workflowAlertDevicePreferences.ts b/application/v2_ui/src/lib/workflowAlertDevicePreferences.ts new file mode 100644 index 000000000..de8c4abc7 --- /dev/null +++ b/application/v2_ui/src/lib/workflowAlertDevicePreferences.ts @@ -0,0 +1,79 @@ +// workflowAlertDevicePreferences.ts +// Per-browser workflow alert switches shared by V2, classic and the alert sound helper. + +import { useSyncExternalStore } from 'react'; + +export const WORKFLOW_ALERT_PLAY_SOUNDS_KEY = 'simplechat.workflowAlerts.playSounds'; +export const WORKFLOW_ALERT_MONITOR_KEY = 'simplechat.workflowAlerts.monitor'; + +type Listener = () => void; + +const listeners = new Set(); + +function readBoolean(key: string, fallback: boolean): boolean { + try { + const value = localStorage.getItem(key); + if (value === null) { + return fallback; + } + return value === 'true'; + } catch { + return fallback; + } +} + +function writeBoolean(key: string, value: boolean): void { + try { + localStorage.setItem(key, value ? 'true' : 'false'); + } catch { + /* A locked-down browser simply keeps the in-memory default. */ + } + notifyWorkflowAlertPreferenceListeners(); +} + +function subscribe(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function notifyWorkflowAlertPreferenceListeners(): void { + for (const listener of [...listeners]) { + listener(); + } +} + +if (typeof window !== 'undefined') { + window.addEventListener('storage', (event) => { + if (event.key === WORKFLOW_ALERT_PLAY_SOUNDS_KEY || event.key === WORKFLOW_ALERT_MONITOR_KEY) { + notifyWorkflowAlertPreferenceListeners(); + } + }); +} + +export function workflowAlertSoundsDeviceEnabled(): boolean { + return readBoolean(WORKFLOW_ALERT_PLAY_SOUNDS_KEY, true); +} + +export function setWorkflowAlertSoundsDeviceEnabled(enabled: boolean): void { + writeBoolean(WORKFLOW_ALERT_PLAY_SOUNDS_KEY, enabled); +} + +export function workflowAlertMonitorEnabled(): boolean { + return readBoolean(WORKFLOW_ALERT_MONITOR_KEY, false); +} + +export function setWorkflowAlertMonitorEnabled(enabled: boolean): void { + writeBoolean(WORKFLOW_ALERT_MONITOR_KEY, enabled); +} + +export function subscribeWorkflowAlertDevicePreferences(listener: Listener): () => void { + return subscribe(listener); +} + +export function useWorkflowAlertDevicePreference(key: 'sound' | 'monitor'): boolean { + return useSyncExternalStore( + subscribe, + () => (key === 'sound' ? workflowAlertSoundsDeviceEnabled() : workflowAlertMonitorEnabled()), + () => (key === 'sound'), + ); +} diff --git a/application/v2_ui/src/lib/workflowAlertNotices.ts b/application/v2_ui/src/lib/workflowAlertNotices.ts index a8a6ea954..93d070108 100644 --- a/application/v2_ui/src/lib/workflowAlertNotices.ts +++ b/application/v2_ui/src/lib/workflowAlertNotices.ts @@ -43,6 +43,10 @@ const MAX_CHIPS = 8; export type WorkflowAlertCategory = 'alert' | 'failure'; export type WorkflowAlertDelivery = 'popup' | 'notify_only'; +export type WorkflowAlertSound = 'off' | 'once' | 'repeat'; +export type WorkflowAlertSize = 'small' | 'medium' | 'large'; +export type WorkflowAlertAudience = 'owner' | 'group'; +export type WorkflowAlertContentScope = 'full' | 'member'; export interface WorkflowAlertRuleMatch { name: string; @@ -69,6 +73,14 @@ export interface WorkflowAlert { priority: WorkflowAlertSeverity; category: WorkflowAlertCategory; delivery: WorkflowAlertDelivery; + requireAcknowledgment: boolean; + sound: WorkflowAlertSound; + size: WorkflowAlertSize; + audience: WorkflowAlertAudience; + acknowledged: boolean; + acknowledgedAt: string | null; + acknowledgedByName: string | null; + contentScope: WorkflowAlertContentScope; createdAt: string; /** Null when the server wrote a date that does not parse. */ createdMs: number | null; @@ -104,6 +116,10 @@ export interface WorkflowAlertEntry { /** The one the entry shows: the loudest, and the newest of those. */ lead: WorkflowAlert; priority: WorkflowAlertSeverity; + requireAcknowledgment: boolean; + sound: WorkflowAlertSound; + size: WorkflowAlertSize; + audience: WorkflowAlertAudience; count: number; /** When the earliest alert in the entry arrived. */ sinceMs: number | null; @@ -116,6 +132,8 @@ const PRIORITY_RANK: Record = { high: 3, critical: 4, }; +const SOUND_RANK: Record = { off: 0, once: 1, repeat: 2 }; +const SIZE_RANK: Record = { small: 0, medium: 1, large: 2 }; export const WORKFLOW_ALERT_PRIORITY_LABELS: Record = { info: 'Info', @@ -190,6 +208,24 @@ function readDelivery(metadata: Record, topLevel: unknown): Wor return oneLine(metadata.delivery || topLevel || '').toLowerCase() === 'notify_only' ? 'notify_only' : 'popup'; } +function readSound(metadata: Record, topLevel: unknown): WorkflowAlertSound { + const value = oneLine(topLevel || metadata.sound || 'off').toLowerCase(); + return value === 'once' || value === 'repeat' ? value : 'off'; +} + +function readSize(metadata: Record, topLevel: unknown): WorkflowAlertSize { + const value = oneLine(topLevel || metadata.size || 'small').toLowerCase(); + return value === 'medium' || value === 'large' ? value : 'small'; +} + +function readAudience(metadata: Record, topLevel: unknown): WorkflowAlertAudience { + return oneLine(topLevel || metadata.audience || 'owner').toLowerCase() === 'group' ? 'group' : 'owner'; +} + +function readContentScope(topLevel: unknown): WorkflowAlertContentScope { + return oneLine(topLevel || 'full').toLowerCase() === 'member' ? 'member' : 'full'; +} + function readTitle(metadata: Record, notification: AppNotification, workflowName: string): string { const explicit = oneLine(metadata.event_title || metadata.alert_title); if (explicit) { @@ -367,6 +403,16 @@ export function readWorkflowAlert(raw: unknown): WorkflowAlert | null { priority: readPriority(metadata, notification), category: readCategory(metadata, notification), delivery: readDelivery(metadata, isRecord(raw) ? raw.delivery : undefined), + requireAcknowledgment: (isRecord(raw) && raw.require_acknowledgment === true) + || metadata.require_acknowledgment === true, + sound: readSound(metadata, isRecord(raw) ? raw.sound : undefined), + size: readSize(metadata, isRecord(raw) ? raw.size : undefined), + audience: readAudience(metadata, isRecord(raw) ? raw.audience : undefined), + acknowledged: (isRecord(raw) && raw.acknowledged === true) + || Boolean(isRecord(raw) && raw.acknowledged_at), + acknowledgedAt: isRecord(raw) ? text(raw.acknowledged_at) || null : null, + acknowledgedByName: isRecord(raw) ? text(raw.acknowledged_by_name) || null : null, + contentScope: readContentScope(isRecord(raw) ? raw.content_scope : undefined), createdAt: notification.created_at, createdMs: parseTime(notification.created_at), workflowId: safeId(metadata.workflow_id), @@ -393,6 +439,9 @@ export function readWorkflowAlert(raw: unknown): WorkflowAlert | null { * An alert with a date that does not parse is left in the bell, where its age does not matter. */ export function isWorkflowAlertPopupEligible(alert: WorkflowAlert, now: number = Date.now()): boolean { + if (alert.requireAcknowledgment && !alert.acknowledged && alert.delivery === 'popup') { + return true; + } if (alert.delivery !== 'popup' || alert.notification.is_read || alert.createdMs === null) { return false; } @@ -409,10 +458,12 @@ function compareAlerts(left: WorkflowAlert, right: WorkflowAlert): number { } /** - * Group alerts by workflow, loudest first. + * Group alerts by workflow: those waiting for acknowledgment first, then loudest first. * - * An entry is as loud as its loudest alert, and among equals the one with the newest alert - * leads, so a storm never hides a fresh critical alert from another workflow behind it. + * An entry that needs acknowledgment comes first whatever its priority, because it is the one + * that won't go away and may be sounding. Otherwise an entry is as loud as its loudest alert, + * and among equals the one with the newest alert leads, so a storm never hides a fresh critical + * alert from another workflow behind it. */ export function groupWorkflowAlerts(alerts: WorkflowAlert[]): WorkflowAlertEntry[] { const groups = new Map(); @@ -431,17 +482,24 @@ export function groupWorkflowAlerts(alerts: WorkflowAlert[]): WorkflowAlertEntry for (const [key, members] of groups) { const newestFirst = [...members].sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0)); const lead = [...members].sort(compareAlerts)[0]; + const strongestSound = [...members].sort((left, right) => SOUND_RANK[right.sound] - SOUND_RANK[left.sound])[0].sound; + const strongestSize = [...members].sort((left, right) => SIZE_RANK[right.size] - SIZE_RANK[left.size])[0].size; const times = members.map((member) => member.createdMs).filter((ms): ms is number => ms !== null); entries.push({ key, alerts: newestFirst, lead, priority: lead.priority, + requireAcknowledgment: members.some((member) => member.requireAcknowledgment && !member.acknowledged), + sound: strongestSound, + size: strongestSize, + audience: members.some((member) => member.audience === 'group') ? 'group' : 'owner', count: members.length, sinceMs: times.length ? Math.min(...times) : null, }); } - return entries.sort((left, right) => compareAlerts(left.lead, right.lead)); + return entries.sort((left, right) => + Number(right.requireAcknowledgment) - Number(left.requireAcknowledgment) || compareAlerts(left.lead, right.lead)); } /** Every notification id an entry stands for. */ @@ -575,7 +633,7 @@ export function workflowAlertFollowUpAction( * failed storage read with a 500 rather than an empty list, and anything that is not a * successful answer is thrown here. The caller then keeps the alerts it already has. */ -export async function fetchWorkflowAlerts(signal?: AbortSignal): Promise<{ alerts: WorkflowAlert[]; complete: boolean }> { +export async function fetchWorkflowAlerts(signal?: AbortSignal): Promise<{ alerts: WorkflowAlert[]; complete: boolean; soundsEnabled: boolean | null }> { const params = new URLSearchParams({ limit: String(WORKFLOW_ALERT_FETCH_LIMIT), since_hours: String(WORKFLOW_ALERT_POPUP_WINDOW_HOURS), @@ -590,6 +648,9 @@ export async function fetchWorkflowAlerts(signal?: AbortSignal): Promise<{ alert return { alerts, // A full page may have left older alerts out; a shorter one is everything there is. - complete: payload.notifications.length < WORKFLOW_ALERT_FETCH_LIMIT, + complete: typeof payload.complete === 'boolean' + ? payload.complete + : payload.notifications.length < WORKFLOW_ALERT_FETCH_LIMIT, + soundsEnabled: typeof payload.sounds_enabled === 'boolean' ? payload.sounds_enabled : null, }; } diff --git a/application/v2_ui/src/lib/workflowAlertSound.ts b/application/v2_ui/src/lib/workflowAlertSound.ts new file mode 100644 index 000000000..059e9aa98 --- /dev/null +++ b/application/v2_ui/src/lib/workflowAlertSound.ts @@ -0,0 +1,382 @@ +// workflowAlertSound.ts +// Workflow alert sounds, played from local files, so that only one tab in a browser sounds. +// +// The sound follows the alerts this tab holds rather than keeping its own list of them: the +// store reports every change (syncWorkflowAlertSound), so an alert acknowledged here, in another +// tab or on another device stops sounding as soon as the store lets it go, however it went. +// +// - Alerts that need acknowledgment and repeat keep one loop going, every five seconds, in the +// tone of the loudest of them, until none is left. The loop holds the Web Lock +// `simplechat.workflowAlertSound`, which classic shares, so another tab waits its turn and +// takes over only when this one stops. Each tick checks the administrator's and the device's +// switches, so turning sound off silences it at once and turning it on resumes it. +// - "Play once" is a one-off. Alerts asked for together chime once, in the tone of the loudest of +// them. A chime never interrupts the loop, skips while another sound holds the lock, and is +// recorded for the whole browser, so another tab doesn't chime for the same alert. +// - A sound the browser refuses (autoplay) is kept, and the next click or key press, or Enable +// sound, tries it again. A refused loop gives up the lock, so a tab that may play takes over. +// A sound that is playing is never restarted by a click. + +import { useSyncExternalStore } from 'react'; +import { useBootstrapStore } from '../stores/bootstrapStore'; +import { subscribeWorkflowAlertDevicePreferences, workflowAlertSoundsDeviceEnabled } from './workflowAlertDevicePreferences'; +import { workflowAlertPriorityRank, type WorkflowAlert } from './workflowAlertNotices'; + +export const WORKFLOW_ALERT_SOUND_REPEAT_MS = 5_000; + +const LOCK_NAME = 'simplechat.workflowAlertSound'; +const SOUNDED_ONCE_KEY = 'simplechat.workflowAlerts.soundedOnce'; +const SOUNDED_ONCE_TTL_MS = 25 * 3_600_000; +const SOUNDED_ONCE_MAX = 500; + +type Listener = () => void; + +const listeners = new Set(); +let adminOverride: boolean | null = null; +/** The tone the loop plays while one is wanted, kept while a refused loop waits for a retry. */ +let loopUrl: string | null = null; +let loopController: AbortController | null = null; +/** The browser refused the loop's tone, and it hasn't played since. */ +let loopRefused = false; +/** A retry of refused one-off sounds is on its way. */ +let retryingOnce = false; +/** One-off sounds this page has started for, so an alert read again doesn't chime again. */ +const triedOnce = new Set(); +/** One-off sounds the browser refused, tried again on the next click or key press. */ +const refusedOnce = new Map(); +/** One-off sounds asked for in this task, which chime together once it ends. */ +const pendingOnce = new Map(); +let chimeQueued = false; + +function emit(): void { + for (const listener of [...listeners]) { + listener(); + } +} + +function adminEnabled(): boolean { + if (adminOverride !== null) { + return adminOverride; + } + return useBootstrapStore.getState().data?.features?.enable_workflow_alert_sounds !== false; +} + +function canPlay(): boolean { + return adminEnabled() && workflowAlertSoundsDeviceEnabled(); +} + +function needsAcknowledgment(alert: WorkflowAlert): boolean { + return alert.requireAcknowledgment && !alert.acknowledged && alert.delivery === 'popup'; +} + +function soundUrl(alert: WorkflowAlert): string { + if (alert.priority === 'critical') { + return '/static/audio/workflow-alerts/alarm.wav'; + } + if (workflowAlertPriorityRank(alert.priority) >= workflowAlertPriorityRank('medium')) { + return '/static/audio/workflow-alerts/urgent.wav'; + } + return '/static/audio/workflow-alerts/chime.wav'; +} + +function loudest(alerts: WorkflowAlert[]): WorkflowAlert { + return [...alerts].sort((left, right) => workflowAlertPriorityRank(right.priority) - workflowAlertPriorityRank(left.priority))[0]; +} + +function isRefusal(error: unknown): boolean { + return error instanceof DOMException && error.name === 'NotAllowedError'; +} + +/** Start a tone. Resolves once it plays; rejects only when the browser refuses to play it. */ +async function startPlayback(url: string): Promise { + try { + await new Audio(url).play(); + } catch (error) { + if (isRefusal(error)) { + throw error; + } + // A file that can't be played is not a refusal: there is nothing to enable or retry. + } +} + +function wait(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener('abort', () => { + clearTimeout(timer); + reject(new DOMException('Aborted', 'AbortError')); + }, { once: true }); + }); +} + +function lockManager(): LockManager | null { + const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined; + return locks && typeof locks.request === 'function' ? locks : null; +} + +function readSoundedOnce(): Record { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(SOUNDED_ONCE_KEY) || '{}'); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}; + } catch { + return {}; + } +} + +function soundedOnce(id: string): boolean { + const at = readSoundedOnce()[id]; + return typeof at === 'number' && Date.now() - at < SOUNDED_ONCE_TTL_MS; +} + +function markSoundedOnce(ids: string[]): void { + try { + const now = Date.now(); + const marking = new Set(ids); + const kept = Object.entries(readSoundedOnce()) + .filter(([id, at]) => !marking.has(id) && typeof at === 'number' && now - at < SOUNDED_ONCE_TTL_MS) + .sort((left, right) => right[1] - left[1]) + .slice(0, Math.max(0, SOUNDED_ONCE_MAX - marking.size)); + const marked = [...marking].map((id) => [id, now] as const); + localStorage.setItem(SOUNDED_ONCE_KEY, JSON.stringify(Object.fromEntries([...marked, ...kept]))); + } catch { + /* Without storage, this page's own record still keeps it to one chime here. */ + } +} + +/** + * Chime once for alerts asked for together: in the tone of the loudest that hasn't chimed in this + * browser yet, recording every one of them. One lock request for the whole batch, so the batch + * makes one sound, rather than whichever alert asked first winning and the rest being dropped. + */ +async function chime(batch: WorkflowAlert[]): Promise { + if (!batch.length || !canPlay()) { + return; + } + // Refusals the batch no longer needs: chimed in another tab, or covered by another sound. + const forget = (alerts: WorkflowAlert[]): void => { + let changed = false; + for (const alert of alerts) { + changed = refusedOnce.delete(alert.id) || changed; + } + if (changed) { + emit(); + } + }; + const run = async (): Promise => { + const fresh = batch.filter((alert) => !soundedOnce(alert.id)); + forget(batch.filter((alert) => !fresh.includes(alert))); + if (!fresh.length) { + return; + } + try { + await startPlayback(soundUrl(loudest(fresh))); + } catch { + fresh.forEach((alert) => refusedOnce.set(alert.id, alert)); + emit(); + return; + } + markSoundedOnce(fresh.map((alert) => alert.id)); + forget(fresh); + }; + const locks = lockManager(); + if (!locks) { + await run(); + return; + } + // Checked and recorded under the lock, so two tabs can't both decide to chime. While another + // sound holds it the browser is already sounding, and a chime would only talk over it. + await locks.request(LOCK_NAME, { ifAvailable: true }, async (lock) => { + if (lock) { + await run(); + } else { + forget(batch); + } + }); +} + +/** Chime for these alerts, together with any others asked for before this task ends. */ +function queueChime(alerts: WorkflowAlert[]): void { + for (const alert of alerts) { + if (!triedOnce.has(alert.id)) { + triedOnce.add(alert.id); + pendingOnce.set(alert.id, alert); + } + } + if (!pendingOnce.size || chimeQueued) { + return; + } + chimeQueued = true; + void Promise.resolve() + .then(() => { + chimeQueued = false; + const batch = [...pendingOnce.values()]; + pendingOnce.clear(); + return chime(batch); + }) + .catch(() => undefined); +} + +async function runLoop(url: string, signal: AbortSignal): Promise { + const body = async (): Promise => { + while (!signal.aborted) { + const startedAt = Date.now(); + if (canPlay()) { + try { + await startPlayback(url); + } catch { + // Refused: let go of the lock, so a tab that may play takes over. + loopRefused = true; + emit(); + return; + } + if (loopRefused) { + loopRefused = false; + emit(); + } + } + await wait(Math.max(0, WORKFLOW_ALERT_SOUND_REPEAT_MS - (Date.now() - startedAt)), signal); + } + }; + const locks = lockManager(); + if (!locks) { + await body(); + return; + } + await locks.request(LOCK_NAME, { mode: 'exclusive', signal }, async () => { + if (!signal.aborted) { + await body(); + } + }); +} + +/** + * Start the loop. A loop the browser refused stays refused -- Enable sound stays up -- until a + * tone of the new one plays, so the control never flickers while the browser still says no. + */ +function startLoop(url: string, refused: boolean): void { + const controller = new AbortController(); + loopController = controller; + loopUrl = url; + loopRefused = refused; + void runLoop(url, controller.signal) + .catch(() => undefined) + .finally(() => { + if (loopController === controller) { + loopController = null; + if (!loopRefused) { + // Ended some other way; the next report from the store starts it again. + loopUrl = null; + } + } + }); +} + +function stopLoop(): void { + loopController?.abort(); + loopController = null; + loopUrl = null; + loopRefused = false; +} + +/** + * Make the sound match the alerts this tab holds: everything waiting and everything shown. The + * store calls this on every change, so a sound stops with the last alert that wanted it. + */ +export function syncWorkflowAlertSound(alerts: WorkflowAlert[]): void { + const held = new Set(alerts.map((alert) => alert.id)); + for (const id of [...refusedOnce.keys()]) { + if (!held.has(id)) { + refusedOnce.delete(id); + } + } + + const waiting = alerts.filter((alert) => needsAcknowledgment(alert) && alert.sound !== 'off'); + const repeating = waiting.filter((alert) => alert.sound === 'repeat'); + const url = repeating.length ? soundUrl(loudest(repeating)) : null; + if (url === null) { + if (loopUrl !== null) { + stopLoop(); + } + } else if (url !== loopUrl) { + const refused = loopRefused; + stopLoop(); + startLoop(url, refused); + } + + queueChime(waiting.filter((alert) => alert.sound === 'once')); + emit(); +} + +/** Chime once for alerts this tab has just shown that don't need acknowledgment. */ +export function playWorkflowAlertOnce(alerts: WorkflowAlert[]): void { + queueChime(alerts.filter((alert) => alert.sound === 'once' && !needsAcknowledgment(alert))); +} + +/** The administrator's setting, from the latest alerts read; null falls back to the bootstrap. */ +export function setWorkflowAlertSoundsEnabled(enabled: boolean | null): void { + if (adminOverride !== enabled) { + adminOverride = enabled; + emit(); + } +} + +/** + * Try the sounds the browser refused again. Called from a click or key press. Enable sound stays + * up until one of them plays, so it never vanishes under the pointer or flickers on a refusal. + */ +export function retryWorkflowAlertSound(): void { + if (!workflowAlertSoundBlocked()) { + return; + } + if (loopRefused && loopUrl !== null && loopController === null) { + startLoop(loopUrl, true); + } + if (refusedOnce.size && !retryingOnce) { + retryingOnce = true; + // The refused chimes are tried again together, as one, in the loudest tone. + void chime([...refusedOnce.values()]) + .catch(() => undefined) + .finally(() => { + retryingOnce = false; + }); + } +} + +/** Whether a sound waits for a click or key press: "Enable sound" shows while it does. */ +export function workflowAlertSoundBlocked(): boolean { + return canPlay() && (loopRefused || refusedOnce.size > 0); +} + +export function subscribeWorkflowAlertSound(listener: Listener): () => void { + listeners.add(listener); + const unsubscribePrefs = subscribeWorkflowAlertDevicePreferences(listener); + return () => { + listeners.delete(listener); + unsubscribePrefs(); + }; +} + +export function useWorkflowAlertSoundBlocked(): boolean { + return useSyncExternalStore(subscribeWorkflowAlertSound, workflowAlertSoundBlocked, () => false); +} + +/** Silence everything and forget what has played. Only the alert lab and tests call this. */ +export function resetWorkflowAlertSoundForLab(): void { + stopLoop(); + triedOnce.clear(); + refusedOnce.clear(); + pendingOnce.clear(); + retryingOnce = false; + try { + localStorage.removeItem(SOUNDED_ONCE_KEY); + } catch { + /* Nothing recorded to forget. */ + } + emit(); +} + +if (typeof window !== 'undefined') { + const retry = () => retryWorkflowAlertSound(); + window.addEventListener('pointerdown', retry, true); + window.addEventListener('keydown', retry, true); +} diff --git a/application/v2_ui/src/lib/workflowAlerts.ts b/application/v2_ui/src/lib/workflowAlerts.ts index 4cc1bbdfd..d7ba10407 100644 --- a/application/v2_ui/src/lib/workflowAlerts.ts +++ b/application/v2_ui/src/lib/workflowAlerts.ts @@ -14,6 +14,9 @@ export const WORKFLOW_ALERT_MODES = ['off', 'every_run', 'rules'] as const; export const WORKFLOW_ALERT_PRIORITIES = ['none', 'low', 'medium', 'high'] as const; export const WORKFLOW_ALERT_SEVERITIES = ['info', 'low', 'medium', 'high', 'critical'] as const; export const WORKFLOW_ALERT_DELIVERIES = ['default', 'notify_only', 'popup'] as const; +export const WORKFLOW_ALERT_SOUNDS = ['off', 'once', 'repeat'] as const; +export const WORKFLOW_ALERT_SIZES = ['small', 'medium', 'large'] as const; +export const WORKFLOW_ALERT_AUDIENCES = ['owner', 'group'] as const; export const WORKFLOW_ALERT_SCOPE_TYPES = ['final', 'any_task', 'task'] as const; export const WORKFLOW_ALERT_CONDITION_TYPES = [ 'run_status', 'task_status', 'text_match', 'file_sync', 'no_output', 'model_evaluation', 'agent_signal', @@ -56,6 +59,10 @@ export type WorkflowAlertPriority = typeof WORKFLOW_ALERT_PRIORITIES[number]; export type WorkflowAlertSeverity = typeof WORKFLOW_ALERT_SEVERITIES[number]; export type WorkflowAlertConditionType = typeof WORKFLOW_ALERT_CONDITION_TYPES[number]; export type WorkflowAlertField = typeof WORKFLOW_ALERT_FIELDS[number]; +export type WorkflowAlertSound = typeof WORKFLOW_ALERT_SOUNDS[number]; +export type WorkflowAlertSize = typeof WORKFLOW_ALERT_SIZES[number]; +export type WorkflowAlertAudience = typeof WORKFLOW_ALERT_AUDIENCES[number]; +export type WorkflowAlertScope = 'personal' | 'group'; /** A stored rule. Fields the editor does not know are kept on the object and sent back. */ export interface WorkflowAlertRule { @@ -64,6 +71,10 @@ export interface WorkflowAlertRule { enabled?: unknown; severity?: string; delivery?: string; + require_acknowledgment?: boolean; + sound?: WorkflowAlertSound; + size?: WorkflowAlertSize; + audience?: WorkflowAlertAudience; scope?: Record; condition?: Record; order?: number; @@ -82,6 +93,7 @@ export interface WorkflowAlertSummary { mode: WorkflowAlertMode; priority: WorkflowAlertPriority; ruleCount: number; + optionSummary: string[]; } /** Python's `str(value or fallback)`: falsy values take the fallback, and other values never match a keyword. */ @@ -106,6 +118,14 @@ export function pyStrip(text: string): string { return text.replace(PY_STRIP, ''); } +function pyTruthy(value: unknown): boolean { + if (typeof value === 'string') return value.length > 0; + if (value === null || value === undefined || value === false || value === 0) return false; + if (Array.isArray(value)) return value.length > 0; + if (isRecord(value)) return Object.keys(value).length > 0; + return true; +} + function includes(values: readonly T[], value: string): value is T { return (values as readonly string[]).includes(value); } @@ -146,13 +166,63 @@ export function workflowAlertConfig(workflow: Record | null): W export function workflowAlertSummary(workflow: Record | null): WorkflowAlertSummary { const config = workflowAlertConfig(workflow); + const ruleOptions = workflowAlertOptionSummary(config.alert_rules); return { mode: config.alert_mode as WorkflowAlertMode, priority: config.alert_priority as WorkflowAlertPriority, ruleCount: config.alert_rules.length, + optionSummary: ruleOptions, }; } +function normalizedOption( + value: unknown, + allowed: readonly T[], + position: number, + label: string, +): T { + const normalized = pyStrip(pyText(value, allowed[0])).toLowerCase() || allowed[0]; + if (!includes(allowed, normalized)) { + refuse(`Alert rule ${position} ${label} must be ${allowed.slice(0, -1).join(', ')} or ${allowed[allowed.length - 1]}.`); + } + return normalized; +} + +function optionValue(rule: Record, key: string, allowed: readonly T[]): T { + const value = pyStrip(pyText(rule[key], allowed[0])).toLowerCase() || allowed[0]; + return includes(allowed, value) ? value : allowed[0]; +} + +function workflowAlertFlag(value: unknown): boolean { + if (typeof value === 'string') return ['1', 'true', 'yes', 'on'].includes(pyStrip(value).toLowerCase()); + return pyTruthy(value); +} + +export function workflowAlertOptionSummary(rules: unknown[]): string[] { + let requireAcknowledgment = false; + let strongestSound = 0; + let strongestSize = 0; + let strongestAudience = 0; + for (const raw of rules) { + if (!isRecord(raw)) continue; + requireAcknowledgment = requireAcknowledgment || workflowAlertFlag(raw.require_acknowledgment); + const sound = optionValue(raw, 'sound', WORKFLOW_ALERT_SOUNDS); + strongestSound = Math.max(strongestSound, WORKFLOW_ALERT_SOUNDS.indexOf(sound)); + const size = optionValue(raw, 'size', WORKFLOW_ALERT_SIZES); + strongestSize = Math.max(strongestSize, WORKFLOW_ALERT_SIZES.indexOf(size)); + const audience = optionValue(raw, 'audience', WORKFLOW_ALERT_AUDIENCES); + strongestAudience = Math.max(strongestAudience, WORKFLOW_ALERT_AUDIENCES.indexOf(audience)); + } + const summary: string[] = []; + if (requireAcknowledgment) summary.push('Must be acknowledged'); + if (strongestSound === 1) summary.push('Plays sound'); + if (strongestSound === 2) summary.push('Repeats sound'); + if (strongestSize === 1) summary.push('Medium'); + if (strongestSize === 2) summary.push('Large'); + if (strongestAudience === 1) summary.push('Everyone in the group'); + return summary; +} + /** The alert fields exactly as stored on a record, for comparison and for the save payload. */ function storedAlertFields(workflow: Record | null): Partial> { const record = workflow ?? {}; @@ -209,7 +279,32 @@ export function workflowAlertRuleEnabled(rule: Record): boolean if (!Object.hasOwn(rule, 'enabled')) return true; const value = rule.enabled; if (typeof value === 'string') return ['1', 'true', 'yes', 'on'].includes(pyStrip(value).toLowerCase()); - return pyText(value) !== ''; + return pyTruthy(value); +} + +export function workflowAlertRuleWithFlagOption( + rule: Record, + key: 'require_acknowledgment', + value: boolean, +): WorkflowAlertRule { + const next = { ...rule } as WorkflowAlertRule; + if (value) next[key] = true; + else delete next[key]; + return next; +} + +export function workflowAlertRuleWithSelectOption( + rule: Record, + key: 'sound' | 'size' | 'audience', + value: WorkflowAlertSound | WorkflowAlertSize | WorkflowAlertAudience, +): WorkflowAlertRule { + const defaults = { sound: 'off', size: 'small', audience: 'owner' } as const; + const next = { ...rule } as WorkflowAlertRule; + if (value === defaults[key]) delete next[key]; + else if (key === 'sound') next.sound = value as WorkflowAlertSound; + else if (key === 'size') next.size = value as WorkflowAlertSize; + else next.audience = value as WorkflowAlertAudience; + return next; } // Condition fields some condition type uses; any other key on a condition is carried across a type change. @@ -370,7 +465,7 @@ export function describeWorkflowAlertCondition(raw: unknown): string { } /** Validate one rule the way `normalize_alert_rule` does, returning its normalized condition. */ -function checkRule(raw: unknown, taskIds: string[] | null, position: number): void { +function checkRule(raw: unknown, taskIds: string[] | null, position: number, workflowScope?: WorkflowAlertScope): void { if (!isRecord(raw)) refuse(`Alert rule ${position} is invalid.`); const condition = normalizedCondition(raw.condition, position); normalizedScope(raw.scope, taskIds, position); @@ -384,6 +479,20 @@ function checkRule(raw: unknown, taskIds: string[] | null, position: number): vo if (!includes(WORKFLOW_ALERT_SEVERITIES, severity)) { refuse(`Alert rule ${position} severity must be info, low, medium, high or critical.`); } + const requireAcknowledgment = workflowAlertFlag(Object.hasOwn(raw, 'require_acknowledgment') ? raw.require_acknowledgment : false); + const sound = normalizedOption(raw.sound, WORKFLOW_ALERT_SOUNDS, position, 'sound'); + const size = normalizedOption(raw.size, WORKFLOW_ALERT_SIZES, position, 'size'); + const audience = normalizedOption(raw.audience, WORKFLOW_ALERT_AUDIENCES, position, 'audience'); + if (audience === 'group' && workflowScope && workflowScope !== 'group') { + refuse(`Alert rule ${position} can alert the whole group only in a group workflow.`); + } + const resolvedDelivery = delivery === 'default' ? WORKFLOW_ALERT_SEVERITY_DELIVERY[severity] : delivery; + if (resolvedDelivery === 'notify_only' && (requireAcknowledgment || sound !== 'off' || size !== 'small')) { + refuse(`Alert rule ${position} must pop up to require acknowledgment, play a sound or change its size.`); + } + if (sound === 'repeat' && !requireAcknowledgment) { + refuse(`Alert rule ${position} can repeat its sound only when it requires acknowledgment.`); + } } function refusal(check: () => void): string | null { @@ -397,7 +506,11 @@ function refusal(check: () => void): string | null { } /** Every rule's first problem, in rule order, as `{position, message}` so a row can show its own. */ -export function workflowAlertRuleErrors(rules: unknown, taskIds: string[] | null): { position: number; message: string }[] { +export function workflowAlertRuleErrors( + rules: unknown, + taskIds: string[] | null, + workflowScope?: WorkflowAlertScope, +): { position: number; message: string }[] { if (rules === null || rules === undefined) return []; if (!Array.isArray(rules)) return [{ position: 0, message: 'Alert rules must be a list.' }]; const errors: { position: number; message: string }[] = []; @@ -405,7 +518,7 @@ export function workflowAlertRuleErrors(rules: unknown, taskIds: string[] | null errors.push({ position: 0, message: `A workflow can have up to ${WORKFLOW_ALERT_MAX_RULES} alert rules.` }); } rules.forEach((rule, index) => { - const message = refusal(() => checkRule(rule, taskIds, index + 1)); + const message = refusal(() => checkRule(rule, taskIds, index + 1, workflowScope)); if (message) errors.push({ position: index + 1, message }); }); return errors; @@ -419,6 +532,7 @@ export function workflowAlertErrors( sent: Partial>, existing: Record | null, taskIds: string[], + workflowScope?: WorkflowAlertScope, ): string[] { const errors: string[] = []; const stored = workflowAlertConfig(existing); @@ -430,7 +544,7 @@ export function workflowAlertErrors( const sendsRules = Object.hasOwn(sent, 'alert_rules'); const rules = sendsRules ? sent.alert_rules : stored.alert_rules; // The server checks task scopes only for rules the request sends, not for stored ones. - const ruleErrors = workflowAlertRuleErrors(rules, sendsRules ? taskIds : null); + const ruleErrors = workflowAlertRuleErrors(rules, sendsRules ? taskIds : null, sendsRules ? workflowScope : undefined); errors.push(...ruleErrors.map((error) => error.message)); let ruleCount = Array.isArray(rules) ? rules.length : 0; @@ -462,7 +576,11 @@ export function workflowAlertErrors( } /** The problems in the alert fields this draft would save, checked against its current tasks. */ -export function workflowAlertDraftErrors(draft: WorkflowDefinition, original: WorkflowDefinition | null): string[] { +export function workflowAlertDraftErrors( + draft: WorkflowDefinition, + original: WorkflowDefinition | null, + workflowScope?: WorkflowAlertScope, +): string[] { const sent = { ...storedAlertFields(original), ...workflowAlertsForSave(draft, original) }; - return workflowAlertErrors(sent, original, draft.tasks.map((task) => task.id)); + return workflowAlertErrors(sent, original, draft.tasks.map((task) => task.id), workflowScope); } diff --git a/application/v2_ui/src/lib/workflowEditor.ts b/application/v2_ui/src/lib/workflowEditor.ts index 5948b84a1..f18d1f317 100644 --- a/application/v2_ui/src/lib/workflowEditor.ts +++ b/application/v2_ui/src/lib/workflowEditor.ts @@ -1728,7 +1728,7 @@ export function workflowValidationErrors( }); if (original !== undefined) { // The editor passes the loaded record so alerts are checked exactly as the server will see them. - errors.push(...workflowAlertDraftErrors(draft, original)); + errors.push(...workflowAlertDraftErrors(draft, original, options.scope.type)); } return [...new Set(errors)]; } diff --git a/application/v2_ui/src/stores/notificationStore.ts b/application/v2_ui/src/stores/notificationStore.ts index 3e702bd82..d984e1a95 100644 --- a/application/v2_ui/src/stores/notificationStore.ts +++ b/application/v2_ui/src/stores/notificationStore.ts @@ -47,6 +47,10 @@ import { type AppNotification, } from '../lib/notifications'; import { toast } from './toastStore'; +import { + subscribeWorkflowAlertDevicePreferences, + workflowAlertMonitorEnabled, +} from '../lib/workflowAlertDevicePreferences'; export type NotificationCountReason = 'initial' | 'poll' | 'focus' | 'visibility' | 'action'; @@ -103,6 +107,7 @@ let changeEpoch = 0; let changesInFlight = 0; let listToken = 0; const listeners = new Set(); +let unsubscribeDevicePreferences: (() => void) | null = null; /** * A change made here that a list page may not show yet. @@ -133,6 +138,10 @@ function isVisible(): boolean { return typeof document === 'undefined' || document.visibilityState === 'visible'; } +function shouldPollWhileHidden(): boolean { + return workflowAlertMonitorEnabled(); +} + function clearTimer(): void { if (timer !== null) { clearTimeout(timer); @@ -142,11 +151,11 @@ function clearTimer(): void { function schedule(): void { clearTimer(); - if (!running || useNotificationStore.getState().halted || !isVisible()) { + if (!running || useNotificationStore.getState().halted || (!isVisible() && !shouldPollWhileHidden())) { return; } // Plus or minus ten percent. - const wait = Math.round(intervalMs * (0.9 + Math.random() * 0.2)); + const wait = shouldPollWhileHidden() ? NOTIFICATION_POLL_BASE_MS : Math.round(intervalMs * (0.9 + Math.random() * 0.2)); timer = setTimeout(() => { timer = null; void readCount('poll'); @@ -257,7 +266,11 @@ function onFocus(): void { function onVisibilityChange(): void { if (!isVisible()) { // Nothing is looking at the bell. The read on return catches up in one request. - clearTimer(); + if (shouldPollWhileHidden()) { + schedule(); + } else { + clearTimer(); + } return; } if (Date.now() - lastReadStartedAt < RETURN_READ_GAP_MS) { @@ -278,6 +291,7 @@ export function startNotificationPolling(): void { running = true; window.addEventListener('focus', onFocus); document.addEventListener('visibilitychange', onVisibilityChange); + unsubscribeDevicePreferences = subscribeWorkflowAlertDevicePreferences(schedule); if (inFlight) { return; } @@ -293,6 +307,8 @@ export function stopNotificationPolling(): void { queuedReason = null; window.removeEventListener('focus', onFocus); document.removeEventListener('visibilitychange', onVisibilityChange); + unsubscribeDevicePreferences?.(); + unsubscribeDevicePreferences = null; } /** diff --git a/application/v2_ui/src/stores/workflowAlertStore.ts b/application/v2_ui/src/stores/workflowAlertStore.ts index 2cb927d79..61a8291a9 100644 --- a/application/v2_ui/src/stores/workflowAlertStore.ts +++ b/application/v2_ui/src/stores/workflowAlertStore.ts @@ -25,6 +25,7 @@ import type { NotificationTarget } from '../lib/notificationLinks'; import type { NotificationNavigationContext } from '../lib/notificationNavigation'; import { claimWorkflowAlerts, resetWorkflowAlertClaims } from '../lib/workflowAlertClaims'; import { tuckIntoTarget } from '../lib/workflowAlertMotion'; +import { playWorkflowAlertOnce, resetWorkflowAlertSoundForLab, syncWorkflowAlertSound } from '../lib/workflowAlertSound'; import { WORKFLOW_ALERT_PRIORITY_LABELS, describeWorkflowAlertGroup, @@ -70,6 +71,11 @@ interface WorkflowAlertState { receiveAlerts: (alerts: WorkflowAlert[], options?: { complete?: boolean }) => void; removeAlerts: (ids: Iterable) => void; + /** + * Let go of alerts someone acknowledged: here, in another tab or as the bell reports. They + * are never shown again on this page, even by a read that left before the acknowledgment. + */ + retireAcknowledged: (ids: Iterable) => void; setSuspended: (suspended: boolean) => void; openCard: () => void; closeCard: () => void; @@ -77,6 +83,7 @@ interface WorkflowAlertState { nextEntry: () => void; markEntryRead: () => Promise; dismissEntry: () => Promise; + acknowledgeEntry: () => Promise; markAllRead: () => Promise; /** * Follow a link or an open action from the card. Every alert in the entry shown is marked @@ -87,6 +94,10 @@ interface WorkflowAlertState { // Ids that must not be presented again: claimed by any tab, or acted on here. const settled = new Set(); +// Alerts acknowledged here, in another tab or as the bell reports. An alert that needs +// acknowledgment is otherwise offered again by every read, so a read that left before the +// acknowledgment must not bring it back. +const acknowledgedIds = new Set(); let claiming = false; // Bumped by a reset, so a claim that was on its way does not present into the new slate. let epoch = 0; @@ -136,6 +147,9 @@ function announcementFor(entries: WorkflowAlertEntry[]): WorkflowAlertAnnounceme const alert = head.lead; const kind = alert.category === 'failure' ? 'workflow run failed' : 'workflow alert'; const parts = [`${WORKFLOW_ALERT_PRIORITY_LABELS[head.priority]} priority ${kind}: ${alert.title}, from ${alert.workflowName}.`]; + if (head.requireAcknowledgment) { + parts.push('Needs acknowledgment.'); + } const group = describeWorkflowAlertGroup(head); if (group) { parts.push(`${group}.`); @@ -144,7 +158,7 @@ function announcementFor(entries: WorkflowAlertEntry[]): WorkflowAlertAnnounceme parts.push(`${entries.length - 1} more waiting.`); } announcementToken += 1; - return { text: parts.join(' '), assertive: head.priority === 'critical', token: announcementToken }; + return { text: parts.join(' '), assertive: head.priority === 'critical' || head.requireAcknowledgment, token: announcementToken }; } function withoutIds(entries: WorkflowAlertEntry[], ids: Set): WorkflowAlertEntry[] { @@ -166,12 +180,13 @@ export const useWorkflowAlertStore = create((set, get) => { const present = (winners: WorkflowAlert[], queue: WorkflowAlert[]): void => { const current = get(); const entries = groupWorkflowAlerts([...current.entries.flatMap((entry) => entry.alerts), ...winners]); + playWorkflowAlertOnce(winners); if (current.phase === 'idle') { announceWhenShown = current.suspended; set({ queue, entries, - phase: 'notice', + phase: entries[0]?.size === 'medium' || entries[0]?.size === 'large' ? 'card' : 'notice', cardIndex: 0, batchToken: current.batchToken + 1, announcement: current.suspended ? null : announcementFor(entries), @@ -200,7 +215,12 @@ export const useWorkflowAlertStore = create((set, get) => { return; } const now = Date.now(); - const candidates = state.queue.filter((alert) => !settled.has(alert.id) && isWorkflowAlertPopupEligible(alert, now)); + // An alert that needs acknowledgment is offered by every read until someone acknowledges it. + // One already on screen stays as it is, rather than being presented, and announced, again. + const showing = new Set(state.entries.flatMap((entry) => entry.alerts.map((alert) => alert.id))); + const candidates = state.queue.filter((alert) => + (alert.requireAcknowledgment && !alert.acknowledged ? !showing.has(alert.id) : !settled.has(alert.id)) + && isWorkflowAlertPopupEligible(alert, now)); if (!candidates.length) { if (state.queue.length) { set({ queue: [] }); @@ -209,7 +229,9 @@ export const useWorkflowAlertStore = create((set, get) => { } claiming = true; const claimEpoch = epoch; - const ids = candidates.map((alert) => alert.id); + const mustAck = candidates.filter((alert) => alert.requireAcknowledgment && !alert.acknowledged); + const claimable = candidates.filter((alert) => !alert.requireAcknowledgment || alert.acknowledged); + const ids = claimable.map((alert) => alert.id); void claimWorkflowAlerts(ids) .catch(() => [] as string[]) .then((won) => { @@ -221,9 +243,10 @@ export const useWorkflowAlertStore = create((set, get) => { settled.add(id); } const current = get(); - const queue = current.queue.filter((alert) => !settled.has(alert.id)); + const presentedIds = new Set([...won, ...mustAck.map((alert) => alert.id)]); + const queue = current.queue.filter((alert) => !presentedIds.has(alert.id) && !settled.has(alert.id)); const wonIds = new Set(won); - const winners = candidates.filter((alert) => wonIds.has(alert.id)); + const winners = candidates.filter((alert) => wonIds.has(alert.id) || mustAck.some((must) => must.id === alert.id)); if (!winners.length) { set({ queue }); } else if (current.phase === 'tucking') { @@ -307,7 +330,10 @@ export const useWorkflowAlertStore = create((set, get) => { receiveAlerts: (alerts, { complete = false } = {}) => { const now = Date.now(); - const queue = alerts.filter((alert) => !settled.has(alert.id) && isWorkflowAlertPopupEligible(alert, now)); + const queue = alerts.filter((alert) => + !acknowledgedIds.has(alert.id) + && (!settled.has(alert.id) || (alert.requireAcknowledgment && !alert.acknowledged)) + && isWorkflowAlertPopupEligible(alert, now)); set({ queue }); if (complete) { // A short answer is every unread pop-up alert there is, so one missing from it @@ -321,6 +347,12 @@ export const useWorkflowAlertStore = create((set, get) => { removeAlerts: (ids) => drop(ids), + retireAcknowledged: (ids) => { + const acknowledged = [...ids]; + acknowledged.forEach((id) => acknowledgedIds.add(id)); + drop(acknowledged); + }, + setSuspended: (suspended) => { if (get().suspended === suspended) { return; @@ -346,7 +378,12 @@ export const useWorkflowAlertStore = create((set, get) => { closeCard: () => { if (get().phase === 'card') { - finish(); + const state = get(); + if (state.entries.some((entry) => entry.requireAcknowledgment)) { + set({ phase: 'notice', growFrom: null, busy: false }); + } else { + finish(); + } } }, @@ -354,6 +391,9 @@ export const useWorkflowAlertStore = create((set, get) => { if (get().phase !== 'notice') { return; } + if (get().entries.some((entry) => entry.requireAcknowledgment)) { + return; + } set({ phase: 'tucking' }); await tuckIntoTarget( document.querySelector('[data-workflow-alert-notice]'), @@ -374,7 +414,10 @@ export const useWorkflowAlertStore = create((set, get) => { markEntryRead: async () => { const entry = get().entries[get().cardIndex]; if (entry) { - await act(workflowAlertEntryIds(entry), (ids) => actions.markRead(ids)); + await act( + entry.alerts.filter((alert) => !alert.requireAcknowledgment).map((alert) => alert.id), + (ids) => actions.markRead(ids), + ); } }, @@ -385,8 +428,27 @@ export const useWorkflowAlertStore = create((set, get) => { } }, + acknowledgeEntry: async () => { + const entry = get().entries[get().cardIndex]; + if (entry) { + await act( + entry.alerts.filter((alert) => alert.requireAcknowledgment && !alert.acknowledged).map((alert) => alert.id), + async (ids) => { + const done = await actions.acknowledge(ids); + done.forEach((id) => acknowledgedIds.add(id)); + return done; + }, + ); + } + }, + markAllRead: async () => { - await act(get().entries.flatMap(workflowAlertEntryIds), (ids) => actions.markRead(ids)); + await act( + get().entries.flatMap((entry) => entry.alerts) + .filter((alert) => !alert.requireAcknowledgment) + .map((alert) => alert.id), + (ids) => actions.markRead(ids), + ); }, openFromCard: async (target, context) => { @@ -396,9 +458,26 @@ export const useWorkflowAlertStore = create((set, get) => { return; } const ids = workflowAlertEntryIds(entry); + const mustAck = entry.alerts.filter((alert) => alert.requireAcknowledgment && !alert.acknowledged).map((alert) => alert.id); + const ordinary = entry.alerts.filter((alert) => !alert.requireAcknowledgment).map((alert) => alert.id); + set({ busy: true }); + const acknowledged = mustAck.length ? await actions.acknowledge(mustAck).catch(() => [] as string[]) : []; + if (mustAck.length && acknowledged.length !== mustAck.length) { + set({ busy: false }); + return; + } + const read = ordinary.length ? actions.markRead(ordinary).catch(() => [] as string[]) : Promise.resolve([] as string[]); + acknowledged.forEach((id) => acknowledgedIds.add(id)); ids.forEach((id) => settled.add(id)); - finish(); - const read = actions.markRead(ids).catch(() => [] as string[]); + // Other alerts that need acknowledgment stay up, in the notice, rather than going with + // the one opened; anything else ends with it, unread in the bell, as before. + const waiting = withoutIds(get().entries, new Set(ids)).filter((item) => item.requireAcknowledgment); + if (waiting.length) { + set({ entries: waiting, phase: 'notice', cardIndex: 0, growFrom: null, busy: false }); + pump(); + } else { + finish(); + } // A full page load would cancel a request still on its way, so a link that leaves // the application waits for the read first. Inside it the read carries on regardless. if (target.kind === 'classic') { @@ -409,14 +488,40 @@ export const useWorkflowAlertStore = create((set, get) => { }; }); +if (typeof BroadcastChannel !== 'undefined') { + try { + const channel = new BroadcastChannel('simplechat.workflowAlerts'); + channel.addEventListener('message', (event) => { + const data = event.data; + if (data && data.type === 'acknowledged' && Array.isArray(data.ids)) { + useWorkflowAlertStore.getState().retireAcknowledged( + data.ids.filter((id: unknown): id is string => typeof id === 'string'), + ); + } + }); + } catch { + /* Cross-tab acknowledgment is best-effort. */ + } +} + +// The sound follows what this tab holds, waiting or shown, so it stops with the last alert that +// wanted it however that alert went: acknowledged, retired by a read, or reset. +useWorkflowAlertStore.subscribe((state, previous) => { + if (state.queue !== previous.queue || state.entries !== previous.entries) { + syncWorkflowAlertSound([...state.queue, ...state.entries.flatMap((entry) => entry.alerts)]); + } +}); + /** Back to a clean slate: nothing waiting, nothing claimed. Only the alert lab and tests call this. */ export function resetWorkflowAlertsForLab(): void { epoch += 1; settled.clear(); + acknowledgedIds.clear(); claiming = false; announceWhenShown = false; heldWinners = []; resetWorkflowAlertClaims(); + resetWorkflowAlertSoundForLab(); useWorkflowAlertStore.setState({ queue: [], entries: [], diff --git a/docs/_data/app_surface.yml b/docs/_data/app_surface.yml index e61b7fc34..b50243abc 100644 --- a/docs/_data/app_surface.yml +++ b/docs/_data/app_surface.yml @@ -1,7 +1,7 @@ # Generated by scripts\build_docs_inventory.py -- do not edit by hand. # Regenerate with: python scripts\build_docs_inventory.py counts: - capabilities: 124 + capabilities: 125 admin_groups: 15 admin_tabs: 49 admin_sections: 110 @@ -259,6 +259,8 @@ capabilities: default: false - key: enable_workflow_ai_assistant default: true +- key: enable_workflow_alert_sounds + default: true admin_groups: - id: agents-actions label: Agents & Actions diff --git a/docs/_data/features.yml b/docs/_data/features.yml index a134d7a5c..8c159bdc0 100644 --- a/docs/_data/features.yml +++ b/docs/_data/features.yml @@ -461,6 +461,19 @@ workflow-results-in-chat: url: /admin/workflow/ - title: "Chat interface controls" url: /reference/chat-controls/ +workflow-alert-sounds: + name: "Workflow alert sounds" + area: "Workflow automation" + audience: "Admins and end users" + summary: "Lets workflow alert rules play a bundled sound once or repeat it until a must-acknowledge alert is acknowledged." + why: "Use this when pop-up workflow alerts need an audible cue for urgent runs, but leave it off in open offices, shared screens or environments with strict accessibility policies. Alerts still appear and still require acknowledgment, and each person can also use the per-device Play alert sounds switch in their browser." + default_state: "Enabled" + admin_tab: workflow + setting_keys: + - enable_workflow_alert_sounds + reference: + - title: "Workflow settings" + url: /admin/workflow/ core-actions: name: "Core actions" area: "Agents and actions" diff --git a/docs/admin/workflow.md b/docs/admin/workflow.md index cde030728..c939ecbd5 100644 --- a/docs/admin/workflow.md +++ b/docs/admin/workflow.md @@ -56,6 +56,7 @@ visible to its members. Turning one on does not turn on the other. | Require WorkflowUser App Role | Restricts personal workflows to holders of the `WorkflowUser` Enterprise App role. Covers opening, creating, editing, running and inspecting them, so assign the role before turning this on or every user loses access at once. | Off | `require_member_of_workflow_user` | | Enable AI Workflow Assistant | Lets people who can edit personal workflows use the V2 workflow editor's **Ask AI** tab: describe a change in plain language, such as "run this at 7 AM on weekdays", and review the assistant's edit before they save it. It also shows each task's **Draft with AI** button. The assistant never saves, runs or shares a workflow, and it only uses documents the person can already open. | On | `enable_workflow_ai_assistant`; personal workflows only; has no effect while Enable Personal Workflows is off | | Use Workflow Results In Chat | Lets people ask chat about the stored result of one of their own finished personal workflow runs, from **Ask in chat** in the run history or **Ask about this** on a workflow alert. The answer uses only that run's saved output: the workflow isn't re-run and nothing else is searched. It works only in the person's private chats, and V2 stops showing an answer once the run is deleted or its stored result changes. Losing access to a document the run's tasks read doesn't hide it, because a saved result takes its access from its workflow and run. Since **0.261.217**, with Chat Orchestration on, it also lets a plan read such a result when the person names the workflow in a request, through the **Read workflow results** capability in [Orchestration settings]({{ '/admin/orchestration/' | relative_url }}#chat-orchestration-capabilities-section). | Off | `enable_chat_workflow_results`; personal workflows only; has no effect while Enable Personal Workflows is off, and follows Require WorkflowUser App Role | +| Enable Workflow Alert Sounds | Allows workflow alert rules to use the bundled one-shot and repeating sounds that help draw attention to urgent pop-ups. Admins may silence them app-wide for open offices, shared screens or accessibility policies while alerts still appear and must still be acknowledged. Individuals still have their own per-device **Play alert sounds** switch for the browser they use. | On | `enable_workflow_alert_sounds`; affects sound only, not popup delivery or acknowledgment | | Enable Group Workflows | Lets permitted members create, manage and run workflows from group workspaces. Owners and Admins may author them unless Workspaces restricts group agent, action and workflow management to Owners. | Off | `allow_group_workflows` | | Require Group Assignment to Use Workflow | Narrows group workflows to an explicit allow list instead of every group. Groups outside the list lose the capability. | Off | `require_group_assignment_for_group_workflows` | | Assigned Groups | The groups that may use group workflows while assignment is required. Ignored when it is not. | Empty list | `group_workflow_allowed_group_ids` | diff --git a/docs/explanation/features/V2_WORKFLOW_ALERT_EDITING.md b/docs/explanation/features/V2_WORKFLOW_ALERT_EDITING.md index b6ffb757f..094d4fe98 100644 --- a/docs/explanation/features/V2_WORKFLOW_ALERT_EDITING.md +++ b/docs/explanation/features/V2_WORKFLOW_ALERT_EDITING.md @@ -34,6 +34,11 @@ Each rule has: - a severity: `info`, `low`, `medium`, `high` or `critical`; - a delivery: `default`, `notify_only` or `popup`. By default, info and low go to the notification bell, and medium and above open the pop-up alert; +- from 0.261.235, pop-up options: whether it must be acknowledged, a sound + (`off`, `once` or `repeat`) and a size (`small`, `medium` or `large`), and in a + group workflow who gets it (`owner` or `group`). The editor shows them under + **Pop-up options** and **Who gets it**, and stores each one only when it isn't the + default. See [Workflow Alert Acknowledgment](WORKFLOW_ALERT_ACKNOWLEDGMENT.md); - a scope: the final output, any task's output, or one task. Only conditions that read output use it: task status, output text, no output, and a model's judgement. Run status, File Sync result and agent alerts apply to the whole @@ -115,6 +120,7 @@ They never echo text you entered: |---|---| | Settings | "Alert priority must be none, low, medium or high." · "Alert mode must be off, every_run or rules." · "Add at least one alert rule, or choose a different alert mode." · "Choose a pop-up priority for alerts on every run, or choose a different alert mode." · "Choose skip or alert for model-evaluated conditions that cannot be judged." · "Alert rules must be a list." · "A workflow can have up to 20 alert rules." | | Rule | "Alert rule {n} is invalid." · "… name must be 120 characters or fewer." · "… delivery must be default, notify_only or popup." · "… severity must be info, low, medium, high or critical." | +| Pop-up options (0.261.235) | "… sound must be off, once or repeat." · "… size must be small, medium or large." · "… audience must be owner or group." · "… can alert the whole group only in a group workflow." · "… must pop up to require acknowledgment, play a sound or change its size." · "… can repeat its sound only when it requires acknowledgment." | | Scope | "Alert rule {n} must look at the final output, any task output, or a specific task." · "… needs a task to watch." · "… watches a task that is no longer in this workflow." | | Condition | "… has an unsupported condition type." · "… run statuses must be a list." · "… has an unsupported run status." · "… needs at least one run status." · the same three for task statuses · "… text match must be contains_any, contains_all, not_contains or regex." · "… needs a regex pattern." · "… regex pattern must be 200 characters or fewer." · "… regex pattern uses nested quantifiers, which are not allowed." · "… regex pattern is not a valid regular expression." · "… match values must be a list of text." · "… match values must each be 400 characters or fewer." · "… needs at least one match value." · "… can match up to 25 values." · "… File Sync result must be changes_found, no_changes or sync_failed." · "… needs a condition for the model to judge." · "… model condition must be 2000 characters or fewer." · "… signal name must be 120 characters or fewer." | diff --git a/docs/explanation/features/V2_WORKFLOW_ALERT_NOTICES.md b/docs/explanation/features/V2_WORKFLOW_ALERT_NOTICES.md index 0a525555c..e6f0b03ad 100644 --- a/docs/explanation/features/V2_WORKFLOW_ALERT_NOTICES.md +++ b/docs/explanation/features/V2_WORKFLOW_ALERT_NOTICES.md @@ -32,6 +32,12 @@ lives on the alerts it creates, so **Open workflow** can find it. ## When a notice appears +From **0.261.235**, an alert whose rule requires acknowledgment is an exception to +several rules below. It skips the one-tab claim and the 24-hour window, pops up again +after every reload until someone acknowledges it, and is never tucked into the bell. +It can also sound, and open as a larger dialog or a full-screen takeover. See +[Workflow Alert Acknowledgment](WORKFLOW_ALERT_ACKNOWLEDGMENT.md). + An alert pops up only when all of these hold: - **Its delivery is pop-up.** The server settles delivery when the alert is raised. @@ -108,11 +114,12 @@ pointing at the item. | Rail | Placement | |---|---| -| Expanded | Drops down below My Workspace and overlays the items under it rather than pushing them, so the conversation list never jumps | +| Expanded | Drops down below My Workspace and overlays the items under it rather than pushing them, so the conversation list never jumps. An alert that needs acknowledgment takes its own room instead, pushing the items under it down, because it stays until someone acknowledges it | | Collapsed to the icon strip | Flies out to the right of the icon | | Mobile | Flies out from the strip's icon, as when collapsed, and waits while the navigation drawer is open | -It shows one entry at a time: the loudest waiting, and then the newest. Each entry +It shows one entry at a time: one that needs acknowledgment first, then the loudest +waiting, and then the newest. Each entry shows: - a priority tag with its icon, which always names the priority in words @@ -139,7 +146,10 @@ stays unread in the bell and, having been claimed, doesn't pop up again. - The notice never takes focus, so typing is never interrupted. - It sits in the tab order right after **My Workspace**, the item it hangs from. - Moving focus onto something the notice covers, such as the rail items under it, - tucks the notice, so it never hides what has focus. + tucks the notice, so it never hides what has focus. A notice that needs + acknowledgment can't tuck. In the full rail it covers nothing, and as a flyout it + steps aside, invisible, until focus moves somewhere it doesn't cover. A new alert + that takes the lead is shown whatever focus did before it arrived. - Each new notice is announced once through a live region. The announcement is polite, or assertive when the loudest alert is critical. For example: "Critical priority workflow alert: Ledger totals do not match, from Payments @@ -260,7 +270,7 @@ A workflow that fails on every run for an hour is one thing to know, not twelve. waiting alerts from one workflow become one entry with a count. The entry leads with its loudest alert, and the newest of those. Its line reads "Failed N times since *time*" when every alert is a failed run, and "N alerts since *time*" otherwise. -Entries are ordered loudest first. Alerts that arrive while the notice or card is +Entries that need acknowledgment come first, then the loudest. Alerts that arrive while the notice or card is up join it and are re-sorted, so a critical alert takes the notice over from a medium one. The card keeps the entry it is showing. diff --git a/docs/explanation/features/WORKFLOW_ALERT_ACKNOWLEDGMENT.md b/docs/explanation/features/WORKFLOW_ALERT_ACKNOWLEDGMENT.md new file mode 100644 index 000000000..77acf6956 --- /dev/null +++ b/docs/explanation/features/WORKFLOW_ALERT_ACKNOWLEDGMENT.md @@ -0,0 +1,318 @@ +# Workflow Alert Acknowledgment, Sounds, Sizes and Team Delivery (v0.261.235) + +## Overview + +A workflow alert used to pop up once and then wait, unread, in the notification bell. +V2 recorded each alert as shown in `localStorage` for 25 hours and classic recorded it in +the tab's `sessionStorage`, so after a refresh the pop-up didn't come back, and V2 also +skipped alerts older than 24 hours. A group workflow's alert reached only the workflow's +owner, and nothing made a sound. + +That's fine for a nightly digest, but not for an operations center, where an alert has to +stay in front of the team, keep sounding until someone takes it on, and show who did. Each +alert rule can now: + +- **require acknowledgment**, so the alert keeps coming back on every page load, tab and + device until someone acknowledges it; +- **play a sound**, once, or every five seconds until the alert is acknowledged; +- choose a **size**: small (the usual pop-up), medium (the full alert opens straight away) + or large (full screen); +- in a group workflow, alert **everyone in the group**, with one acknowledgment that clears + the alert for all of them. + +Implemented in version: **0.261.235**, tracked in `application/single_app/config.py`. +Issue: [#1634](https://github.com/microsoft/simplechat/issues/1634). + +Dependencies: + +- [Workflow Alert Rules](WORKFLOW_ALERT_RULES.md), which decide whether a run alerts and how loudly. +- [V2 Workflow Alert Notices](V2_WORKFLOW_ALERT_NOTICES.md) and the classic alert modal + ([Workflow Priority Alerts](WORKFLOW_PRIORITY_ALERTS.md)), which show alerts. +- [V2 Workflow Alert Editing](V2_WORKFLOW_ALERT_EDITING.md) and the classic workflow editor. + +## The rule options + +| Option | Values | Notes | +|---|---|---| +| Require acknowledgment | Off (default), on | The rule must pop up | +| Sound | Off (default), Play once, Repeat until acknowledged | The rule must pop up; repeating needs acknowledgment | +| Size | Small (default), Medium, Large (full screen) | The rule must pop up | +| Who gets it | Workflow owner (default), Everyone in the group | Group workflows only; also works for bell-only rules | + +Rules store an option only when it isn't the default, so rules saved before this release keep +their exact shape. The "On every run" mode has no options. + +When several rules match one run, severity, category and delivery still come from the +highest-severity rule. Each option instead takes the **strongest value any matched rule asked +for**: one rule requiring acknowledgment makes the alert require it, and the loudest sound, the +largest size and the widest audience apply. An option that only makes sense as a pop-up forces +the delivery to pop-up. + +For the same reason, a model-evaluated rule is no longer skipped just because a deterministic +rule already matched at a higher severity. It is still sent to the model when it asks for an +option the matches don't already give. Otherwise a failed run that also met that rule would get +a weaker alert than a completed one. + +### Validation + +`normalize_alert_rule` in `functions_workflow_alerts.py` checks, after the existing checks and +in this order, and answers with these reviewed messages (`{n}` is the rule's position): + +1. "Alert rule {n} sound must be off, once or repeat." +2. "Alert rule {n} size must be small, medium or large." +3. "Alert rule {n} audience must be owner or group." +4. "Alert rule {n} can alert the whole group only in a group workflow." +5. "Alert rule {n} must pop up to require acknowledgment, play a sound or change its size." + This covers a delivery of **Notification bell only**, and **Default for severity** with an + info or low severity. +6. "Alert rule {n} can repeat its sound only when it requires acknowledgment." + +`normalize_workflow_alert_settings` takes a `workflow_scope` (`personal` or `group`), passed by +both save paths, for check 4. Stored rules that weren't sent with the request are carried over +without that check, as their task references already are. The acknowledgment flag is read the +way `enabled` always has been: the strings `1`, `true`, `yes` and `on` mean on. + +The V2 editor (`workflowAlertErrors` in `lib/workflowAlerts.ts`) mirrors these checks word for +word, and `functional_tests/test_workflow_alert_client_parity.py` runs it against the real +normalizer. The classic editor shows the server's message on save. + +## Who receives an alert + +An owner-audience alert is personal to the workflow's owner, as before. + +When any matched rule asks for the group in a group workflow, the runner +(`_create_workflow_priority_alert`) creates **one group-scoped alert** for that group instead, +through `create_workflow_priority_notification(..., group_id=...)`. It records the owner in +`metadata.owner_user_id`. A rule can't save a group audience in a personal workflow. If one +reaches the runner anyway, the runner logs a warning and alerts the owner instead. + +### What members see + +A team alert is built from the owner's side of the run. Its summary and detail can quote +conversations and actions in the owner's own space, and its links include the owner's workflow +conversation and private conversations, which members can't open. So `_decorate_workflow_alert` +gives every reader except the owner a simpler alert, in the bell list and in the pop-up read +alike. It tells them there is an alert, how severe it is, and offers **Open** to what the run +created in the group. The run's output stays in the group's workflow run history, under that +history's own access rules. The simpler alert keeps and drops these: + +| Kept | Removed or rebuilt | +|---|---| +| Severity, category, delivery, the pop-up options | Title, rebuilt as "*Priority* priority workflow alert: *workflow*" | +| Workflow name and id, scope, group, run id, run status, trigger, runner | Message, rebuilt as "Matched *rule names*. Open it for details." | +| Matched rules' ids, names, severities and condition types | Rule reasons, summary, detail, response preview, error, event and alert titles, enrichments, agent names | +| Links to group conversations the run created in this group | The owner's workflow conversation, personal conversations, other groups' links | + +Every reader gets only their own read and dismissed state, never other members' ids. Responses +say which view they carry in `content_scope` (`full` or `member`). A team alert without a recorded +owner is reduced for everyone. + +## Acknowledgment + +These acknowledge a must-acknowledge alert: the **Acknowledge** button, and **Open** on the alert +itself. Opening an alert is a deliberate response to it. + +These never acknowledge: closing it, Escape, the backdrop, a refresh, Mark read, Dismiss, and +**Mark all read**, whether in the bell or on the alert. + +`POST /api/notifications//acknowledge` (`acknowledge_workflow_alert` in +`functions_notifications.py`): + +- Only a recipient can acknowledge: the owner of a personal alert, or a **current** member of a + team alert's group. Membership is checked with `assert_group_role` and every group workflow + member role. Anyone else gets `404`, so the answer never confirms an alert they can't see. +- An alert that doesn't need acknowledgment gets `400`. +- It records `acknowledged_at` and `acknowledged_by` (`user_id` and display name), and marks the + alert read for the caller. One acknowledgment clears a team alert for every member. Each + member's bell entry stays unread for them, and reads "Acknowledged by *name* at *time*". +- **The first acknowledgment wins.** The write is conditional on the alert's ETag and retried on + a conflict. A later or losing acknowledgment answers `200` with `already_acknowledged: true` + and the first acknowledger. + +A shared alert is written by many members, so `mark_notification_read` and +`dismiss_notification` now make the same ETag-conditional write (`_write_notification_change`). +Before, they upserted the copy they had read. A member marking a team alert read with a copy +from before someone's acknowledgment would have erased that acknowledgment, and two members +reading at once could drop one read. + +### Reading alerts that should pop up + +`GET /api/notifications/workflow-alerts` now uses `get_workflow_alert_popups`. It makes four +bounded reads, each capped at `limit`: + +1. the user's unread pop-up alerts (unchanged, `get_unread_workflow_priority_notifications`); +2. the user's must-acknowledge alerts nobody has acknowledged, **whatever their age or the + user's read or dismissed state**; +3. unread pop-up team alerts from the groups the user belongs to now; +4. unacknowledged must-acknowledge team alerts from those groups. + +Must-acknowledge alerts have reads of their own, so newer alerts can't crowd them out of `TOP`. +`since_hours` narrows only the unread reads. Group ids come from the server (`get_user_groups`), +never from the request, and the group reads are one cross-partition query each. The response +adds: + +- `complete`: false when any read came back full, so V2 no longer infers completeness from the + list's length; +- `sounds_enabled`: the administrator's setting. + +Every workflow alert, here and in `GET /api/notifications`, carries top-level +`require_acknowledgment`, `sound`, `size`, `audience`, `acknowledged`, `acknowledged_at`, +`acknowledged_by_name` and `content_scope`. + +## How an alert appears + +| Size | V2 | Classic | +|---|---|---| +| Small | The notice under **My Workspace**, which opens into the full alert | The usual centered dialog | +| Medium | The full alert opens straight away, in a wider dialog | A wider dialog (`modal-lg`) with larger type | +| Large | A full-screen takeover with large type and the severity band | A full-screen dialog (`modal-fullscreen`) | + +V2 still waits for the page to be free, with no other dialog open, before it opens a medium or +large alert. + +A must-acknowledge alert: + +- comes back after a refresh, in every tab and on every device, and is never tucked into the bell. + V2 skips its one-tab claim and its 24-hour window, and classic doesn't record it as shown; +- comes first. V2 puts alerts that need acknowledgment ahead of every other alert, whatever their + severity, because they are the ones that won't go away and may be sounding; +- shrinks to a persistent notice when it is closed or Escape is pressed. In V2 the notice has no + close button and doesn't time out. In the full rail it takes its own room below + **My Workspace**, pushing the items under it down, rather than covering them as an ordinary + notice does for a few seconds. As a flyout beside the collapsed rail it steps aside, invisible, + when focus moves onto something it covers, and comes back when focus moves on. A new alert that + takes the lead is shown whatever focus did before it arrived. Classic keeps a slim + banner at the bottom of the page, "*N* workflow alerts need acknowledgment", with **Review**; +- shows **Acknowledge** in place of **Dismiss**, or in place of **Mark read** when there is nothing + to open. With somewhere to open, **Open** stays the one green button, since it acknowledges too. + Opening one alert acknowledges that one only: any other alert that needs acknowledgment stays, + in the notice, and keeps sounding; +- can be acknowledged from the bell, where opening its row acknowledges it as well. Marking it read + or dismissing it there leaves it waiting; +- disappears from every other tab of the browser at once, through the `BroadcastChannel` + `simplechat.workflowAlerts` that classic and V2 share. Other devices and teammates notice when + they next check: V2 re-checks every 20 seconds while such an alert is waiting, even in a + background tab, and classic checks every 20 to 40 seconds. + +A team alert says it was sent to everyone in the group. + +## Sound + +Three tones were synthesized for SimpleChat by `scripts/generate_workflow_alert_sounds.py`. It +uses only the Python standard library and is deterministic. The tones are served locally from +`static/audio/workflow-alerts/`: + +| File | Severities | +|---|---| +| `chime.wav` | Info, low | +| `urgent.wav` | Medium, high | +| `alarm.wav` | Critical | + +- **Play once** chimes once per browser: when the alert is shown, or for a must-acknowledge alert, + when the browser first receives it. Alerts that arrive together chime once, in the tone of the + loudest of them. The browser records each chime in `localStorage` + (`simplechat.workflowAlerts.soundedOnce`, kept 25 hours, at most 500 alerts), checked and + written while holding the lock, so another tab, classic or V2, doesn't chime again for the same + alert, and neither does a reload. A chime never plays over a repeating sound. +- **Repeat until acknowledged** sounds every five seconds (`WORKFLOW_ALERT_SOUND_REPEAT_MS`). One + loop sounds for every alert that repeats, in the tone of the loudest, until the last one is + acknowledged. Acknowledging one leaves the loop going for the rest, in the next one's tone. +- A must-acknowledge alert starts sounding as soon as the browser receives it, even if the tab is + in the background or a dialog holds the visual back. It stops as soon as it is acknowledged, + here, in another tab, on another device or by a teammate, even if it was never shown. +- Only one tab per browser plays, the one holding the Web Lock `simplechat.workflowAlertSound`. + Classic and V2 share the lock, so a classic tab and a V2 tab don't sound together. A tab whose + sound the browser refuses gives the lock up, so a tab that may play takes over. +- Sound plays only when all of these allow it: the rule's sound, the administrator's **Enable + Workflow Alert Sounds** (`enable_workflow_alert_sounds`, on by default), and the device's **Play + alert sounds** switch. +- Sound is never the only signal. The alert is always shown as well. + +### When the browser blocks sound + +Browsers don't play sound on a page nobody has clicked or typed in since it loaded. When that +happens the alert shows **Enable sound**, and the next click or key press anywhere on the page +tries the sound again. **Enable sound** stays until a sound actually plays, so it doesn't vanish +under the pointer or flicker while the browser still refuses. A refused sound is dropped, not +retried, once its alert is acknowledged or closed. + +A wall display that reloads unattended can allow SimpleChat to play sound without a click, through +the browser's enterprise policy. In Microsoft Edge and Google Chrome, add the SimpleChat site to +**Allow media autoplay on specific sites** (`AutoplayAllowlist`). + +## Settings + +| Setting | Where | Default | Effect | +|---|---|---|---| +| Enable Workflow Alert Sounds (`enable_workflow_alert_sounds`) | Admin Settings, Workflow | On | Off silences every workflow alert sound for everyone. Alerts still pop up and still need acknowledgment. Sent to V2 in the bootstrap and with each alerts read | +| Play alert sounds | V2 Preferences ("Workflow alerts on this device"), and the classic Profile settings | On | Per browser (`localStorage` `simplechat.workflowAlerts.playSounds`), shared by classic and V2 | +| Alert monitor | V2 Preferences ("Workflow alerts on this device") | Off | Per browser (`localStorage` `simplechat.workflowAlerts.monitor`). V2 checks every 30 seconds without backing off, even in a background tab, for operations screens | + +Without the monitor, V2 checks every 30 seconds, backs off to five minutes while nothing changes, +and doesn't check while the tab is hidden. + +## Files + +| File | Change | +|---|---| +| `application/single_app/functions_workflow_alerts.py` | Rule options, validation, scope, strongest-option merge, forced pop-up | +| `application/single_app/functions_personal_workflows.py`, `functions_group_workflows.py` | Pass the workflow scope to the normalizer | +| `application/single_app/functions_workflow_runner.py` | Options on the alert and run decision; one group-scoped alert for a group audience | +| `application/single_app/functions_notifications.py` | Group-scoped creation, member reduction, the four-read pop-up query, acknowledgment, ETag-conditional read and dismiss | +| `application/single_app/route_backend_notifications.py` | The acknowledge route; `complete` and `sounds_enabled` | +| `application/single_app/functions_workflow_activity.py` | The options in activity's rule list | +| `application/single_app/functions_settings.py`, `admin_settings_fields.py`, `route_frontend_admin_settings.py`, `templates/admin/_panes/workflow.html`, `route_backend_v2.py` | `enable_workflow_alert_sounds` | +| `scripts/generate_workflow_alert_sounds.py`, `static/audio/workflow-alerts/*.wav` | The tones | +| `application/v2_ui/src/lib/workflowAlerts.ts`, `components/workflows/WorkflowAlertEditor.tsx`, `WorkflowAlertSummary.tsx`, `WorkflowEditorDialog.tsx`, `lib/workflowEditor.ts` | V2 editor and validation mirror | +| `application/v2_ui/src/lib/workflowAlertNotices.ts`, `lib/notifications.ts` | V2: the new fields, eligibility of alerts that need acknowledgment, the server's `complete` flag | +| `application/v2_ui/src/stores/workflowAlertStore.ts`, `lib/workflowAlertActions.ts` | V2: acknowledging, minimizing instead of tucking, Open acknowledging and leaving other pending alerts up, Mark all read skipping, never re-presenting an acknowledged alert, the shared channel | +| `application/v2_ui/src/lib/workflowAlertSound.ts`, `lib/workflowAlertDevicePreferences.ts` | New: V2 sound player, which follows the alerts the store holds, and the per-device switches | +| `application/v2_ui/src/lib/useWorkflowAlertRuntime.ts`, `stores/notificationStore.ts` | V2: re-reading every 20 seconds while an alert waits for acknowledgment, the Alert monitor, the bell no longer retiring an alert that was only read | +| `application/v2_ui/src/components/notifications/WorkflowAlertNotice.tsx`, `.css`, `WorkflowAlertCard.tsx`, `components/ui/Modal.tsx` | V2: the tags, Acknowledge, Enable sound, the notice taking its own room in the rail and stepping aside as a flyout, sizes and the `full` dialog size | +| `application/v2_ui/src/components/layout/NotificationPanel.tsx`, `components/settings/PreferencesTab.tsx` | V2: the bell's Acknowledge and "Acknowledged by", and "Workflow alerts on this device" | +| `application/v2_ui/src/dev/AlertLabPage.tsx`, `alertLabSamples.ts` | Alert lab scenarios for each size, sound, blocked audio, a member's view and acknowledgment elsewhere | +| `application/single_app/static/js/workspace/workspace_workflows.js`, `templates/workspace.html`, `templates/group_workspaces.html` | Classic editor | +| `application/single_app/static/js/workflow-alert-sound.js` | New: the classic sound player, with one repeating loop, the shared lock and "sounded once" record, and the gates | +| `application/single_app/static/js/notifications.js`, `templates/base.html` | Classic alert dialog: re-showing, Acknowledge, the banner, sizes, the team line, the shared channel, and the notifications page's Acknowledge and "Acknowledged by" | +| `application/single_app/templates/profile.html` | Classic per-device sound switch | + +## Testing and validation + +| Suite | Covers | +|---|---| +| `functional_tests/test_workflow_alert_acknowledgment.py` | Every option and reviewed message, the strongest-option merge, model-evaluated rules still sent when they ask for a stronger option, the runner's group alert and fallback, acknowledgment authorization (owner, member, non-member, deleted group), idempotency, the first acknowledgment winning a race, stale reads and dismissals not undoing it, Mark all read not acknowledging, the member reduction in the bell and the pop-up read, the four bounded reads and `complete` | +| `functional_tests/route_tests/test_workflow_alert_acknowledge_policy.py` | The route's Blueprint, Swagger and authentication decorators, the session identity, every response shape, no exception text in errors | +| `functional_tests/route_tests/test_workflow_alert_since_hours_policy.py` | The alerts route's new fields and failure shapes | +| `functional_tests/test_workflow_alert_client_parity.py` | The V2 editor accepts and refuses exactly what the server does, with the same messages, in both scopes | +| `functional_tests/test_workflow_alert_sounds_setting.py`, `test_workflow_alert_sound_assets.py` | The admin setting end to end; the tones, their format and the generator's determinism | +| `functional_tests/test_workflow_alert_sound_parity.py` | Classic and V2 share the lock, storage keys, limits, timing, channel and tones | +| `functional_tests/test_workflow_alert_notifications_import_cycle.py` | CodeQL's `py/cyclic-import` counts imports made inside functions, so it reports the existing chain `functions_notifications` → `functions_group` → `functions_settings` → `content_screening.service` → `functions_notifications`, which closes only through such imports. The test keeps any module-level cycle out of `functions_notifications`, and loads the real modules cold and offline in every order the app reaches them (web routes, the scheduler's workflow runner, settings, screening and the group helpers first), with and without `-O` | +| `ui_tests/test_v2_workflow_alerts.py`, `ui_tests/test_workspace_workflow_alert_rules.py` | Both editors | +| `ui_tests/test_v2_workflow_alert_notices.py` | V2: bypassing claims and the 24-hour window, no tuck, Escape minimizing, Acknowledge and Open acknowledging, Mark all read skipping, medium and large sizes and the large dialog's height, large type, the sound's once, repeat, blocked and gated paths, no extra beeps on clicks or key presses, Play once playing once across reads and once between two tabs, alerts arriving together chiming once in the loudest tone, Enable sound retrying refused chimes as one, a showing alert not presented again, Enable sound as its own control, re-reads every 20 seconds, broadcast retirement, a waiting alert acknowledged elsewhere going quiet, no retry for a refused alert acknowledged elsewhere, Open and Acknowledge leaving the next pending alert up and sounding in its tone, pending alerts leading, the notice taking its own room in the full rail and stepping aside as a flyout, a flyout that stepped aside showing the next alert that leads, the member view, the Preferences switches, the Alert monitor while hidden, and the bell's Acknowledge, read and open | +| `ui_tests/test_v2_notifications_bell.py` | The bell's Acknowledge and "Acknowledged by" | +| `ui_tests/test_workflow_alert_classic_acknowledgment.py` | The real classic scripts on an offline page: a must-acknowledge alert returning after a reload while an ordinary one stays suppressed, Acknowledge, the banner and **Review**, staying in the banner across polls, acknowledgment elsewhere by poll or broadcast, a capped read not retiring an alert, the repeat interval and every sound gate, resuming sound when it is turned back on, **Play once** playing once, **Enable sound** in the dialog and banner, a refused sound giving up the lock, **Enable sound** retrying a refused chime once, alerts arriving together chiming once in the loudest tone, refused chimes retried as one, a chime another tab played not repeated, **Mark as read** for an alert without links, sizes, the team line, and polls skipping the alerts read when nothing needs it | + +## Known limitations + +- After each page load, sound waits for a click or key press unless the browser policy allows the + site to autoplay. +- Background tabs are throttled by the browser, so a repeating sound in a hidden tab can drift by + a second or so. +- Members other than the owner see a simpler alert, and reach the run's output through **Open** + or the group's workflow run history. +- A team alert reaches the group's current members. An owner who has left the group no longer sees + it. +- A page loaded before this release treats a must-acknowledge alert as an ordinary pop-up until it + is reloaded. +- A group manager's rule can make every member's browser sound. Admins can turn sounds off, and + each person can turn them off on their own device. There is no per-member opt-out from team + alerts yet. +- The AI workflow assistant keeps a rule's options when it edits a workflow, but can't set them yet. +- Run activity doesn't show acknowledgments yet. + +## Related + +- [Workflow Alert Rules](WORKFLOW_ALERT_RULES.md) +- [V2 Workflow Alert Notices](V2_WORKFLOW_ALERT_NOTICES.md) +- [V2 Workflow Alert Editing](V2_WORKFLOW_ALERT_EDITING.md) +- [Workflow Priority Alerts](WORKFLOW_PRIORITY_ALERTS.md) diff --git a/docs/explanation/features/WORKFLOW_ALERT_RULES.md b/docs/explanation/features/WORKFLOW_ALERT_RULES.md index 51900cd13..c0eccb069 100644 --- a/docs/explanation/features/WORKFLOW_ALERT_RULES.md +++ b/docs/explanation/features/WORKFLOW_ALERT_RULES.md @@ -72,6 +72,10 @@ Alert settings live on the workflow document: "enabled": true, "severity": "info | low | medium | high | critical", "delivery": "default | notify_only | popup", + "require_acknowledgment": true, // stored only when true (0.261.235) + "sound": "once | repeat", // stored only when not "off" (0.261.235) + "size": "medium | large", // stored only when not "small" (0.261.235) + "audience": "group", // group workflows only, stored only when not "owner" (0.261.235) "scope": { "type": "final | any_task | task", "task_id": "" }, "condition": { "type": "...", /* type specific fields */ } } @@ -143,6 +147,25 @@ detail opens with a `Triggered by` section listing each matched rule, its severi One run produces at most one notification no matter how many rules match. +### Pop-up options and audience + +From **0.261.235** a rule can also ask for acknowledgment, a sound, a size and, in a group +workflow, the whole group as its audience. See +[Workflow Alert Acknowledgment, Sounds, Sizes and Team Delivery](WORKFLOW_ALERT_ACKNOWLEDGMENT.md). + +| Field | Values | Rule | +|---|---|---| +| `require_acknowledgment` | `false` (default), `true` | Needs a rule that pops up | +| `sound` | `off` (default), `once`, `repeat` | Needs a rule that pops up; `repeat` needs `require_acknowledgment` | +| `size` | `small` (default), `medium`, `large` | Needs a rule that pops up | +| `audience` | `owner` (default), `group` | Group workflows only; works with bell-only rules too | + +Unlike severity, these options don't follow the winning rule. Each takes the **strongest value any +matched rule asked for**: any rule requiring acknowledgment makes the alert require it, and the +loudest sound, the largest size and the widest audience apply. A louder rule that didn't ask for an +option can't quieten it. An option that only makes sense as a pop-up also forces the delivery to +`popup`, even when the winning rule only notifies. `every_run` mode keeps the defaults. + ## Model evaluated conditions A `model_evaluation` rule holds a plain-English condition such as *"any certificate expires within @@ -152,8 +175,10 @@ tightly bounded: - **One call per run at most.** Every pending model evaluated rule is batched into a single prompt and the model returns one JSON verdict per rule. - **Skipped when moot.** If a deterministic rule already matched at or above a model rule's - severity, that model rule cannot change the outcome and is not sent. When no model rule remains, - no model client is even resolved. + severity, that model rule cannot change the outcome and is not sent, unless it asks for a pop-up + option the matches don't already give (acknowledgment, a louder sound, a larger size or the whole + group; see [Pop-up options and audience](#pop-up-options-and-audience)). When no model rule + remains, no model client is even resolved. - **Bounded input.** The scoped output is truncated before it enters the prompt. - **Strict output.** The response is parsed as JSON, tolerating markdown fences and surrounding chatter. `alert_evaluation.on_error` decides what happens when it cannot be parsed or the call diff --git a/docs/explanation/features/WORKFLOW_PRIORITY_ALERTS.md b/docs/explanation/features/WORKFLOW_PRIORITY_ALERTS.md index 6339acac4..7e29b947e 100644 --- a/docs/explanation/features/WORKFLOW_PRIORITY_ALERTS.md +++ b/docs/explanation/features/WORKFLOW_PRIORITY_ALERTS.md @@ -77,6 +77,6 @@ Validation focus: ## Known Limitations -- The modal suppresses repeat display for the same alert within the current browser session, even if the user closes it without marking it read. +- The modal suppresses repeat display for the same alert within the current browser session, even if the user closes it without marking it read. From 0.261.235, an alert whose rule requires acknowledgment is the exception: it shows again on every page load until someone acknowledges it. See `docs/explanation/features/WORKFLOW_ALERT_ACKNOWLEDGMENT.md`. - Only agent actions captured through the existing plugin invocation logger can contribute extra linked conversations beyond the default workflow conversation. - Alert delivery remains polling-based, so scheduled workflow alerts are near-real-time rather than push-driven. \ No newline at end of file diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index bf04ddc59..7a060c68e 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,21 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.235)** + +#### New Features + +* **Must-Acknowledge Workflow Alerts, Alert Sounds, Sizes And Team Delivery** + * Each workflow alert rule can now require acknowledgment. Such an alert keeps coming back after a refresh, in every tab and on every device, whatever its age, until someone selects **Acknowledge** or opens it. It is shown ahead of other alerts. Closing it only shrinks it: V2 keeps its notice under **My Workspace**, in room of its own so it never covers the items below, and classic keeps a banner with **Review** at the bottom of the page. Mark read, Dismiss and **Mark all read** never acknowledge an alert. + * A rule can play a sound once, or repeat it every five seconds until the alert is acknowledged. Three tones, chosen by severity, were synthesized for SimpleChat and are served locally. One tab per browser plays, shared between classic and V2: several repeating alerts sound as one tone, the most urgent one's, and **Play once** chimes once per browser, not once per tab or refresh, with alerts that arrive together chiming once in the most urgent tone. When the browser blocks sound, the alert offers **Enable sound** until a sound plays. + * A rule can choose a size: small (the usual pop-up), medium (the full alert opens straight away) or large (full screen, for wall displays). + * In a group workflow, a rule can alert everyone in the group. The run raises one shared alert, and any current member can acknowledge it for everyone; the bell then shows "Acknowledged by *name* at *time*". Members other than the owner see a simpler alert with the severity, workflow, matched rules and **Open** to what the run created in the group. + * When several rules match, each option takes the strongest value any matched rule asked for. A model-evaluated rule that asks for a stronger option is still evaluated when a deterministic rule matched at a higher severity. + * New admin setting **Enable Workflow Alert Sounds** (`enable_workflow_alert_sounds`, on by default). Each device has its own **Play alert sounds** switch, and V2 **Preferences** adds an **Alert monitor** switch that checks for workflow alerts every 30 seconds, even in a background tab, for operations screens. + * New route `POST /api/notifications//acknowledge`, open only to the alert's recipients. `GET /api/notifications/workflow-alerts` now also returns pending alerts that need acknowledgment and team alerts from the user's groups, with `complete` and `sounds_enabled`. Read, dismiss and acknowledge writes are now conditional on the stored version, so concurrent writes to a shared alert can't drop each other. + * Both the V2 and classic workflow editors have the new options, and both mirror the server's reviewed validation messages. + * (Ref: #1634, `functions_workflow_alerts.py`, `functions_notifications.py`, `route_backend_notifications.py`, `functions_workflow_runner.py`, `WorkflowAlertEditor.tsx`, `workflowAlertStore.ts`, `workflowAlertSound.ts`, `PreferencesTab.tsx`, `notifications.js`, `workflow-alert-sound.js`, `workspace_workflows.js`, [Workflow Alert Acknowledgment](features/WORKFLOW_ALERT_ACKNOWLEDGMENT.md)) + ### **(v0.261.234)** #### Bug Fixes diff --git a/docs/guides/create-a-workflow.md b/docs/guides/create-a-workflow.md index f72bc1a59..9bb064722 100644 --- a/docs/guides/create-a-workflow.md +++ b/docs/guides/create-a-workflow.md @@ -259,6 +259,44 @@ alert settings is shown with the rule's number. Members who can't manage the workflow see a read-only summary. A workflow saved in V2 can no longer be opened by the classic editor. +### Make an alert hard to miss + +From version **0.261.235**, each rule also has **Pop-up options**. Use them for +alerts someone must see and act on, such as a failure that an operations team has to +pick up: + +- **Require acknowledgment** keeps the alert coming back after a refresh, in every + tab, and on every device, until someone selects **Acknowledge** or opens it. + Closing the alert only shrinks it, and **Mark all read** never clears it. +- **Sound** plays a tone that gets more urgent with the severity. **Play once** + sounds when the alert pops up. **Repeat until acknowledged** sounds every five + seconds until someone acknowledges the alert, so choosing it also turns on + **Require acknowledgment**. +- **Size** decides how much of the screen the alert takes. **Small** is the usual + pop-up. **Medium** opens the full alert straight away. **Large (full screen)** + fills the screen, which suits a wall display. + +These options only apply to alerts that pop up. A rule that goes to the bell, such +as an info rule left at **Default for severity**, can't use them until you set its +delivery to **Pop-up alert**. When several rules match one run, the alert gets the +strongest option any of them asked for, even if the rule with the highest severity +didn't ask for it. **On every run** alerts don't have these options. To get them, +use a rule such as **Run finished with a status: Completed**. + +### Alert everyone in the group + +In a group workflow, **Who gets it** chooses between the **Workflow owner** and +**Everyone in the group**. A team alert is a single shared alert. When any member +acknowledges it, it stops popping up and sounding for everyone, and each member's +bell shows who acknowledged it and when. + +Members other than the owner see a simpler alert: its severity, the workflow, the rules +that matched, and **Open** to what the run created in the group, such as a group +conversation. The full alert describes the owner's side of the run, including +conversations in the owner's own space, so only the owner sees it. + +The classic workflow editor has the same options. + ## Choose the Microsoft 365 Run as account Native V2 **Run as** authoring is implemented in version **0.261.122**. Use diff --git a/docs/guides/manage-notifications.md b/docs/guides/manage-notifications.md index 47b179a13..b77e4deac 100644 --- a/docs/guides/manage-notifications.md +++ b/docs/guides/manage-notifications.md @@ -142,6 +142,65 @@ Press Enter on it to open the full alert. Focus moves into the alert, and return where you were when it closes. Screen readers announce each notice once, after what they are reading; a critical alert is announced straight away. +## Alerts that need acknowledgment + +Since **0.261.235**, a workflow's owner can make an alert rule require +acknowledgment, play a sound, take more of the screen, or reach everyone in a group +workflow's group. These alerts are for things someone has to act on, such as a failure +an operations team must pick up, so they stay in front of you until someone responds. +See [Make an alert hard to miss]({{ '/guides/create-a-workflow/#make-an-alert-hard-to-miss' | relative_url }}). + +An alert that needs acknowledgment is marked **Needs acknowledgment**, and: + +- **It keeps coming back.** It pops up again after you refresh, in every tab, and on + every device, however old it is, until someone acknowledges it. Closing it, or + pressing Escape, only shrinks it: V2 keeps its notice under **My Workspace**, where + it takes its own room rather than covering the items below it, and classic keeps a + banner at the bottom of the page with **Review**. It never tucks into the bell, + and **Mark all read** doesn't clear it. Alerts that need acknowledgment are shown + before any others, whatever their severity. +- **Select Acknowledge to clear it.** **Acknowledge** replaces **Dismiss**. Selecting + **Open** acknowledges the alert too, because you're responding to it. Any other alert + that needs acknowledgment stays up. The alert then + disappears from your other tabs straight away, and from other devices when they next + check. In the bell, the alert has its own **Acknowledge** button, and opening it from + the bell acknowledges it too. Marking it read or dismissing it there leaves it waiting. +- **It may sound.** The tone gets more urgent with the severity, and may repeat every + five seconds until someone acknowledges the alert. Only one tab in your browser plays + it, and when several alerts repeat you hear one tone, the most urgent one's. An alert + set to **Play once** chimes once in your browser, not again in another tab or after a + refresh, and alerts that arrive together chime once, in the most urgent tone. +- **It may be larger.** A medium alert opens in full straight away. A large alert fills + the screen. + +A team alert reaches everyone in the group and says so. When any member acknowledges +it, it stops for everyone, and the bell shows "Acknowledged by *name* at *time*". If +you aren't the workflow's owner, the alert is simpler: its severity, the workflow, the +rules that matched, and **Open** to what the run created in the group. + +### Sound on this device + +Browsers don't play sound on a page nobody has clicked or typed in since it loaded. +If that stops an alert's sound, the alert shows **Enable sound**. Select it, or click +anywhere on the page, and the sound starts. **Enable sound** goes away once a sound +has played. + +To silence workflow alert sounds on one device, turn off **Play alert sounds** under +**Workflow alerts on this device** in V2 **Preferences**, or **Play workflow alert +sounds on this device** in the classic Profile settings. It applies to this browser +only, in both interfaces. Administrators can turn workflow alert sounds off for +everyone. + +### Keep an operations screen watching + +On a screen that has to notice alerts within seconds, turn on **Alert monitor** under +**Workflow alerts on this device** in V2 **Preferences**. V2 then checks for workflow +alerts every 30 seconds, even when the tab is in the background, rather than slowing +down while nothing changes. For a display that reloads unattended, ask your browser +administrator to add SimpleChat to **Allow media autoplay on specific sites** +(`AutoplayAllowlist` in Microsoft Edge and Google Chrome). Sound can then play without +anyone clicking first. + ## Verify it worked Unread notifications lose unread styling after being marked read. Filters and search update the list without changing unrelated notifications. @@ -158,6 +217,10 @@ Unread notifications lose unread styling after being marked read. Filters and se | A workflow alert pops up later than expected | V2 checks for new notifications every 30 seconds, less often while nothing changes, and holds the notice while a dialog is open | Return to the tab or close the dialog. Switching back to the tab checks straight away. | | The notice tucked away before I read it | Info, low and medium notices tuck into the bell after about eight seconds | Open the bell; the alert is still unread there. Point at a notice to keep it open. | | The full alert has no **Open workflow** | The alert was raised before 0.261.199 and doesn't say which workspace its workflow belongs to | Open the workflow from its workspace's workflows list. | +| A workflow alert keeps coming back | Its rule requires acknowledgment | Select **Acknowledge** or **Open**. Mark read, Dismiss and Mark all read don't clear it. | +| A workflow alert shows **Enable sound** | The browser blocks sound until you click or type on the page | Select **Enable sound**, or click anywhere on the page. | +| A workflow alert makes no sound | Sound is off on this device, an administrator turned workflow alert sounds off, or another tab in this browser is playing it | Check **Play alert sounds** in V2 **Preferences**, or ask an administrator about **Enable Workflow Alert Sounds**. | +| An operations screen shows alerts late | V2 slows down while nothing changes and doesn't check in a background tab | Turn on **Alert monitor** in V2 **Preferences** on that screen. | ## Related diff --git a/docs/guides/update-profile-preferences.md b/docs/guides/update-profile-preferences.md index 3b578f350..cc4f7deea 100644 --- a/docs/guides/update-profile-preferences.md +++ b/docs/guides/update-profile-preferences.md @@ -45,6 +45,7 @@ Profile preferences are for personal comfort and control: font size, navigation capture="Capture the update profile preferences task at this step in SimpleChat with realistic sample data and redact secrets." %} 6. If shown, configure **Desktop Conversation Notifications**, **Fact Memory**, retention settings, microphone permission, or text-to-speech settings. +7. In **Workflow Alert Sounds**, choose whether workflow alerts can play sounds in this browser. The switch takes effect at once and applies only to this browser, so a shared operations screen and your own laptop can differ. See [Alerts that need acknowledgment]({{ '/guides/manage-notifications/#alerts-that-need-acknowledgment' | relative_url }}). ## Microsoft 365 data preferences diff --git a/functional_tests/route_tests/test_workflow_alert_acknowledge_policy.py b/functional_tests/route_tests/test_workflow_alert_acknowledge_policy.py new file mode 100644 index 000000000..ca33a8c97 --- /dev/null +++ b/functional_tests/route_tests/test_workflow_alert_acknowledge_policy.py @@ -0,0 +1,180 @@ +# test_workflow_alert_acknowledge_policy.py +""" +Functional policy tests for the workflow alert acknowledgment route. +Version: 0.261.235 +Implemented in: 0.261.235 + +POST /api/notifications//acknowledge clears a must-acknowledge workflow alert +for everyone who receives it. The real route body runs on a closed Flask app with a recording +acknowledgment function, so these tests pin the route's decorators, the identity it passes on, +and every response shape, including that failures never echo exception text. No live +application, permissions, credentials or services are used. +""" + +import ast +import logging +from pathlib import Path +import sys +from unittest.mock import Mock + +import pytest +from flask import Flask, jsonify, request + +ROOT = Path(__file__).resolve().parents[2] +APP = ROOT / "application" / "single_app" +sys.path.insert(0, str(APP)) +sys.path.insert(0, str(ROOT / "functional_tests")) + +# Shared test helpers follow the isolated worktree import setup. +from test_support.versioning import assert_app_version_at_least + + +ROUTES = APP / "route_backend_notifications.py" +ROUTE_PATH = "/api/notifications//acknowledge" + + +def route_function(): + tree = ast.parse(ROUTES.read_text(encoding="utf-8")) + registrar = next( + node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "register_route_backend_notifications" + ) + return next( + node for node in registrar.body + if isinstance(node, ast.FunctionDef) + and node.decorator_list + and isinstance(node.decorator_list[0], ast.Call) + and node.decorator_list[0].args + and getattr(node.decorator_list[0].args[0], "value", None) == ROUTE_PATH + ) + + +@pytest.fixture +def acknowledge_api(): + """The real route body, without its decorators, over a recording acknowledgment function.""" + state = {"result": ("acknowledged", None), "error": None} + calls = [] + bumps = [] + log_event = Mock() + + def acknowledge(notification_id, user_id, display_name=""): + calls.append({"notification_id": notification_id, "user_id": user_id, "display_name": display_name}) + if state["error"]: + raise state["error"] + return state["result"] + + namespace = { + "request": request, + "jsonify": jsonify, + "logging": logging, + "log_event": log_event, + "get_current_user_id": lambda: "member-1", + "get_current_user_info": lambda: {"userId": "member-1", "displayName": "Jane Operator", "email": "jane@example.com"}, + "acknowledge_workflow_alert": acknowledge, + "bump_conversation_cache_version": lambda user_id, reason="": bumps.append((user_id, reason)), + } + function = route_function() + function.decorator_list = [] + exec(compile(ast.Module(body=[function], type_ignores=[]), str(ROUTES), "exec"), namespace) + app = Flask("workflow-alert-acknowledge") + app.add_url_rule(ROUTE_PATH, endpoint="acknowledge", view_func=namespace[function.name], methods=["POST"]) + return app.test_client(), state, calls, bumps, log_event + + +def acknowledged(notification_id="alert-1", already=False): + status = "already_acknowledged" if already else "acknowledged" + return status, { + "id": notification_id, + "acknowledged_at": "2026-10-03T15:00:00+00:00", + "acknowledged_by_name": "Jane Operator", + } + + +def test_route_keeps_its_blueprint_swagger_and_auth_policy(): + assert_app_version_at_least("0.261.235") + decorators = [ast.unparse(value) for value in route_function().decorator_list] + + assert decorators == [ + "bp.route('/api/notifications//acknowledge', methods=['POST'])", + "swagger_route(security=get_auth_security())", + "login_required", + "user_required", + ] + + +def test_acknowledging_passes_the_signed_in_identity_and_reports_who_and_when(acknowledge_api): + client, state, calls, bumps, _ = acknowledge_api + state["result"] = acknowledged() + + response = client.post("/api/notifications/alert-1/acknowledge") + + assert response.status_code == 200 + assert response.json == { + "success": True, + "notification_id": "alert-1", + "acknowledged_at": "2026-10-03T15:00:00+00:00", + "acknowledged_by_name": "Jane Operator", + "already_acknowledged": False, + } + # The identity comes from the session, never from the request. + assert calls == [{"notification_id": "alert-1", "user_id": "member-1", "display_name": "Jane Operator"}] + assert bumps == [("member-1", "workflow_alert_acknowledged")] + + +def test_an_alert_someone_already_acknowledged_reports_the_first_acknowledgment(acknowledge_api): + client, state, _, _, _ = acknowledge_api + state["result"] = acknowledged(already=True) + + response = client.post("/api/notifications/alert-1/acknowledge") + + assert response.status_code == 200 + assert response.json["already_acknowledged"] is True + assert response.json["acknowledged_by_name"] == "Jane Operator" + + +def test_an_alert_the_caller_cannot_receive_is_not_found(acknowledge_api): + client, state, _, bumps, _ = acknowledge_api + state["result"] = ("not_found", None) + + response = client.post("/api/notifications/someone-elses-alert/acknowledge") + + assert response.status_code == 404 + assert response.json == {"success": False, "error": "Alert not found."} + assert bumps == [] + + +def test_an_alert_that_needs_no_acknowledgment_is_refused(acknowledge_api): + client, state, _, bumps, _ = acknowledge_api + state["result"] = ("not_required", None) + + response = client.post("/api/notifications/alert-1/acknowledge") + + assert response.status_code == 400 + assert response.json == {"success": False, "error": "This alert does not need acknowledgment."} + assert bumps == [] + + +def test_a_failure_is_logged_without_echoing_its_text(acknowledge_api): + client, state, _, _, log_event = acknowledge_api + state["error"] = RuntimeError("cosmos://secret-endpoint exploded") + + response = client.post("/api/notifications/alert-1/acknowledge") + + assert response.status_code == 500 + assert response.json == {"success": False, "error": "Unable to acknowledge the alert."} + assert "secret-endpoint" not in response.get_data(as_text=True) + log_event.assert_called_once() + assert log_event.call_args.kwargs["extra"] == {"user_id": "member-1", "error_type": "RuntimeError"} + + +def test_get_is_not_an_acknowledgment(acknowledge_api): + client, _, calls, _, _ = acknowledge_api + + response = client.get("/api/notifications/alert-1/acknowledge") + + assert response.status_code == 405 + assert calls == [] + + +if __name__ == "__main__": + sys.exit(pytest.main([str(Path(__file__).resolve()), "-q"])) diff --git a/functional_tests/route_tests/test_workflow_alert_since_hours_policy.py b/functional_tests/route_tests/test_workflow_alert_since_hours_policy.py index 964a53bb7..053c55540 100644 --- a/functional_tests/route_tests/test_workflow_alert_since_hours_policy.py +++ b/functional_tests/route_tests/test_workflow_alert_since_hours_policy.py @@ -1,7 +1,7 @@ # test_workflow_alert_since_hours_policy.py """ Functional policy tests for the workflow alert pop-up route's since_hours window. -Version: 0.261.199 +Version: 0.261.235 Implemented in: 0.261.199 The real route body runs on a closed Flask app with the real since_hours parser, and with @@ -56,7 +56,11 @@ def load_notifications_module(query_items=None): "config": {"cosmos_notifications_container": container}, "functions_appinsights": {"log_event": Mock()}, "functions_debug": {"debug_print": Mock()}, - "functions_group": {"find_group_by_id": Mock(), "get_user_groups": lambda user: []}, + "functions_group": { + "assert_group_role": Mock(), + "find_group_by_id": Mock(), + "get_user_groups": lambda user: [], + }, "functions_public_workspaces": { "find_public_workspace_by_id": Mock(), "get_user_public_workspaces": lambda user: [], @@ -72,13 +76,14 @@ def load_notifications_module(query_items=None): return module -def route_client(reader, parser): +def route_client(reader, parser, sounds_enabled=True): """The real route body, without its decorators, on a closed Flask app.""" namespace = { "request": request, "jsonify": jsonify, "get_current_user_id": lambda: "owner", - "get_unread_workflow_priority_notifications": reader, + "get_workflow_alert_popups": reader, + "get_settings": lambda: {"enable_workflow_alert_sounds": sounds_enabled}, "parse_workflow_alert_since_hours": parser, "debug_print": Mock(), } @@ -114,7 +119,7 @@ def reader(user_id, limit=5, since_hours=None, raise_on_error=False): }) if state["fail"]: raise RuntimeError("storage unavailable") - return [{"id": "alert-1", "priority": "high", "delivery": "popup"}] + return {"notifications": [{"id": "alert-1", "priority": "high", "delivery": "popup"}], "complete": True} parser = load_notifications_module().parse_workflow_alert_since_hours return route_client(reader, parser), calls, state @@ -131,7 +136,7 @@ def query_items(*args, **kwargs): notifications = load_notifications_module(query_items) client = route_client( - notifications.get_unread_workflow_priority_notifications, + notifications.get_workflow_alert_popups, notifications.parse_workflow_alert_since_hours, ) return client, attempts @@ -146,6 +151,8 @@ def test_classic_request_is_unchanged(alerts_api): assert response.json == { "success": True, "notifications": [{"id": "alert-1", "priority": "high", "delivery": "popup"}], + "complete": True, + "sounds_enabled": True, } assert calls == [{"user_id": "owner", "limit": 5, "since_hours": None, "raise_on_error": False}] @@ -156,10 +163,26 @@ def test_v2_request_passes_a_validated_window(alerts_api): response = client.get(ROUTE_PATH, query_string={"limit": 10, "since_hours": 24}) assert response.status_code == 200 - assert set(response.json) == {"success", "notifications"} + assert set(response.json) == {"success", "notifications", "complete", "sounds_enabled"} assert calls == [{"user_id": "owner", "limit": 10, "since_hours": 24, "raise_on_error": True}] +def test_sounds_enabled_follows_the_admin_setting(): + """Clients silence workflow alert sounds when the administrator turns them off.""" + assert_app_version_at_least("0.261.235") + parser = load_notifications_module().parse_workflow_alert_since_hours + client = route_client( + lambda user_id, **kwargs: {"notifications": [], "complete": True}, + parser, + sounds_enabled=False, + ) + + response = client.get(ROUTE_PATH, query_string={"limit": 5}) + + assert response.status_code == 200 + assert response.json["sounds_enabled"] is False + + @pytest.mark.parametrize(("limit", "expected"), [("0", 5), ("11", 5), ("1", 1), ("10", 10)]) def test_out_of_range_limits_fall_back_as_before(alerts_api, limit, expected): client, calls, _ = alerts_api @@ -206,14 +229,15 @@ def test_a_failed_storage_read_is_a_500_for_v2(failing_storage_api): def test_a_failed_storage_read_is_still_an_empty_list_for_classic(failing_storage_api): - """Classic has always shown nothing for a failed read, and still does.""" + """Classic has always shown nothing for a failed read, and still does; the list says it's partial.""" client, attempts = failing_storage_api response = client.get(ROUTE_PATH, query_string={"limit": 5}) assert response.status_code == 200 - assert response.json == {"success": True, "notifications": []} - assert attempts == ["owner"] + assert response.json == {"success": True, "notifications": [], "complete": False, "sounds_enabled": True} + # The unread read and the must-acknowledge read both stay in the caller's own partition. + assert attempts == ["owner", "owner"] if __name__ == "__main__": diff --git a/functional_tests/test_group_workflow_file_sync_client_parity.py b/functional_tests/test_group_workflow_file_sync_client_parity.py index 98843b5b5..d35f0f564 100644 --- a/functional_tests/test_group_workflow_file_sync_client_parity.py +++ b/functional_tests/test_group_workflow_file_sync_client_parity.py @@ -590,6 +590,7 @@ def test_the_alert_summary_resolves_stored_alerts_like_the_server(parity): for name, record in ALERT_RECORDS.items(): server = resolve(copy.deepcopy(record)) summary = client["alerts"][name] - assert summary == { + assert {key: summary[key] for key in ("mode", "priority", "ruleCount")} == { "mode": server["alert_mode"], "priority": server["alert_priority"], "ruleCount": len(server["alert_rules"]), }, name + assert isinstance(summary["optionSummary"], list), name diff --git a/functional_tests/test_workflow_alert_acknowledgment.py b/functional_tests/test_workflow_alert_acknowledgment.py new file mode 100644 index 000000000..543cdbd1b --- /dev/null +++ b/functional_tests/test_workflow_alert_acknowledgment.py @@ -0,0 +1,906 @@ +#!/usr/bin/env python3 +# test_workflow_alert_acknowledgment.py +""" +Functional test for must-acknowledge workflow alerts, sound, size and team delivery. +Version: 0.261.235 +Implemented in: 0.261.235 + +This test ensures that alert rules accept and validate the new pop-up options with reviewed +messages; that a run's decision takes the strongest option any matched rule asked for and +forces a pop-up when an option needs one; that the runner raises one shared group alert only +for a group workflow; that only an alert's recipients can acknowledge it, the first +acknowledgment wins and read, dismiss and mark-all-read never acknowledge; that concurrent +writes to a shared alert can't drop an acknowledgment; that group members other than the +workflow's owner never receive the run's output; and that must-acknowledge alerts keep +popping up regardless of age or read state. Storage is an in-memory stand-in, so no Azure +service is used. +""" + +import ast +from copy import deepcopy +from datetime import datetime, timedelta, timezone +import importlib.util +import json +import logging +from pathlib import Path +import sys +import types +from unittest.mock import Mock, patch + +import pytest +from azure.cosmos import exceptions as cosmos_exceptions + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "application" / "single_app" +sys.path.insert(0, str(APP)) +sys.path.insert(0, str(ROOT / "functional_tests")) + +from test_support.versioning import assert_app_version_at_least # noqa: E402 + +from functions_workflow_alerts import ( # noqa: E402 + build_workflow_alert_facts, + evaluate_workflow_alert_rules, + normalize_alert_rules, + normalize_workflow_alert_settings, +) +from functions_workflow_definitions import WorkflowAlertValidationError # noqa: E402 + + +FAILED_RULE = { + "name": "Run failed", + "severity": "high", + "delivery": "popup", + "condition": {"type": "run_status", "statuses": ["failed"]}, +} + + +def rule(**overrides): + value = deepcopy(FAILED_RULE) + value.update(overrides) + return value + + +def save(rules, scope=None, existing=None): + return normalize_workflow_alert_settings( + {"alert_mode": "rules", "alert_rules": rules}, + existing_workflow=existing, + task_ids=[], + workflow_scope=scope, + ) + + +def refusal(rules, scope=None): + with pytest.raises(WorkflowAlertValidationError) as caught: + save(rules, scope=scope) + return str(caught.value) + + +# --- Rule options ------------------------------------------------------------------------- + + +def test_default_options_are_not_stored_so_existing_rules_keep_their_shape(): + assert_app_version_at_least("0.261.235") + stored = save([rule(require_acknowledgment=False, sound="off", size="small", audience="owner")], scope="group") + + assert set(stored["alert_rules"][0]) == { + "id", "name", "enabled", "severity", "delivery", "scope", "condition", "order", + } + + +def test_non_default_options_are_stored_normalized(): + stored = save( + [rule(require_acknowledgment=" Yes ", sound=" REPEAT ", size="Large", audience="group")], + scope="group", + )["alert_rules"][0] + + assert stored["require_acknowledgment"] is True + assert stored["sound"] == "repeat" + assert stored["size"] == "large" + assert stored["audience"] == "group" + + +@pytest.mark.parametrize( + ("value", "expected"), + [(True, True), ("on", True), ("1", True), (" TRUE ", True), ("no", False), ("0", False), (0, False), (None, False)], +) +def test_acknowledgment_flag_reads_like_enabled(value, expected): + stored = save([rule(require_acknowledgment=value)])["alert_rules"][0] + + assert stored.get("require_acknowledgment", False) is expected + + +@pytest.mark.parametrize( + ("overrides", "scope", "message"), + [ + ({"sound": "siren"}, None, "Alert rule 1 sound must be off, once or repeat."), + ({"size": "huge"}, None, "Alert rule 1 size must be small, medium or large."), + ({"audience": "everyone"}, None, "Alert rule 1 audience must be owner or group."), + ({"audience": "group"}, "personal", "Alert rule 1 can alert the whole group only in a group workflow."), + ( + {"delivery": "notify_only", "require_acknowledgment": True}, + None, + "Alert rule 1 must pop up to require acknowledgment, play a sound or change its size.", + ), + ( + {"severity": "info", "delivery": "default", "sound": "once"}, + None, + "Alert rule 1 must pop up to require acknowledgment, play a sound or change its size.", + ), + ( + {"severity": "low", "delivery": "default", "size": "medium"}, + None, + "Alert rule 1 must pop up to require acknowledgment, play a sound or change its size.", + ), + ({"sound": "repeat"}, None, "Alert rule 1 can repeat its sound only when it requires acknowledgment."), + ], +) +def test_each_invalid_option_is_refused_with_its_reviewed_message(overrides, scope, message): + refused = refusal([rule(**overrides)], scope=scope) + + assert refused == message + + +def test_messages_name_the_rule_position_and_follow_the_documented_order(): + # The sound is checked before the size, and both before the cross-field checks. + second_rule = refusal([rule(), rule(sound="loud", size="huge")]) + bell_only_repeat = refusal([rule(delivery="notify_only", sound="repeat")]) + + assert second_rule == "Alert rule 2 sound must be off, once or repeat." + assert bell_only_repeat == ( + "Alert rule 1 must pop up to require acknowledgment, play a sound or change its size." + ) + + +def test_group_audience_is_accepted_in_a_group_workflow_and_unchecked_without_a_scope(): + in_group = save([rule(audience="group")], scope="group") + unscoped = save([rule(audience="group")], scope=None) + + assert in_group["alert_rules"][0]["audience"] == "group" + assert unscoped["alert_rules"][0]["audience"] == "group" + + +def test_stored_rules_that_were_not_sent_are_carried_over_without_the_scope_check(): + existing = {"alert_mode": "rules", "alert_rules": [rule(id="kept", audience="group")]} + + stored = normalize_workflow_alert_settings({}, existing_workflow=existing, workflow_scope="personal") + + assert stored["alert_rules"][0]["audience"] == "group" + + +def test_medium_and_above_pop_up_by_default_so_they_accept_options(): + stored = save([rule(severity="medium", delivery="default", require_acknowledgment=True, sound="repeat")]) + + assert stored["alert_rules"][0]["sound"] == "repeat" + + +def test_both_save_paths_pass_their_scope(): + personal = (APP / "functions_personal_workflows.py").read_text(encoding="utf-8") + group = (APP / "functions_group_workflows.py").read_text(encoding="utf-8") + + assert "workflow_scope='personal'," in personal + assert "workflow_scope='group'," in group + + +# --- Decision --------------------------------------------------------------------------- + + +def decide(rules, status="failed"): + workflow = { + "id": "wf-1", + "name": "Watch", + "user_id": "owner", + "alert_mode": "rules", + "alert_rules": normalize_alert_rules(rules), + } + facts = build_workflow_alert_facts(workflow, {"id": "run-1", "status": status}, {}) + return evaluate_workflow_alert_rules(workflow, facts) + + +def test_the_strongest_option_wins_while_severity_follows_the_winning_rule(): + decision = decide([ + rule(name="Loud", severity="critical", delivery="popup"), + rule(name="Must see", severity="medium", require_acknowledgment=True, sound="repeat", size="medium", + audience="group"), + rule(name="Big", severity="low", delivery="popup", size="large"), + ]) + + assert decision["should_alert"] is True + assert decision["severity"] == "critical" + assert decision["winning_rule_name"] == "Loud" + assert decision["require_acknowledgment"] is True + assert decision["sound"] == "repeat" + assert decision["size"] == "large" + assert decision["audience"] == "group" + assert decision["delivery"] == "popup" + + +def test_an_option_forces_a_pop_up_even_when_the_winning_rule_only_notifies(): + decision = decide([ + rule(name="Quiet", severity="critical", delivery="notify_only"), + rule(name="Ring", severity="medium", sound="once"), + ]) + + assert decision["severity"] == "critical" + assert decision["delivery"] == "popup" + assert decision["sound"] == "once" + + +def test_rules_without_options_keep_todays_decision(): + decision = decide([rule(severity="info", delivery="default")]) + + assert decision["delivery"] == "notify_only" + assert decision["require_acknowledgment"] is False + assert (decision["sound"], decision["size"], decision["audience"]) == ("off", "small", "owner") + + +def test_a_run_that_matches_nothing_reports_default_options(): + decision = decide([rule()], status="completed") + + assert decision["should_alert"] is False + assert decision["require_acknowledgment"] is False + assert decision["sound"] == "off" + + +def test_every_run_mode_is_unchanged(): + workflow = {"id": "wf-1", "user_id": "owner", "alert_mode": "every_run", "alert_priority": "high"} + facts = build_workflow_alert_facts(workflow, {"id": "run-1", "status": "completed"}, {}) + + decision = evaluate_workflow_alert_rules(workflow, facts) + + assert decision["delivery"] == "popup" + assert decision["require_acknowledgment"] is False + assert decision["audience"] == "owner" + + +def decide_with_model(rules, status="failed"): + """Evaluate with a model that judges every condition it is asked about as met.""" + workflow = { + "id": "wf-2", + "name": "Team watch", + "user_id": "owner", + "group_id": "group-7", + "alert_mode": "rules", + "alert_rules": normalize_alert_rules(rules, workflow_scope="group"), + } + prompts = [] + + def evaluator(prompt): + prompts.append(prompt) + model_rules = [rule for rule in workflow["alert_rules"] if rule["condition"]["type"] == "model_evaluation"] + return json.dumps({"results": [ + {"rule_id": rule["id"], "matched": True, "reason": "The condition is met."} for rule in model_rules + ]}) + + facts = build_workflow_alert_facts(workflow, {"id": "run-1", "status": status}, {}) + return evaluate_workflow_alert_rules(workflow, facts, model_evaluator=evaluator), prompts + + +MODEL_RULE = { + "id": "customer-impact", + "name": "Customer impact", + "severity": "medium", + "delivery": "popup", + "condition": {"type": "model_evaluation", "prompt": "Customers are affected."}, +} + + +def test_a_lower_severity_model_rule_still_contributes_its_options(): + """A failing run must not alert more weakly than a completed one because a model rule was skipped.""" + model_rule = {**MODEL_RULE, "require_acknowledgment": True, "sound": "repeat", "audience": "group"} + + failed, failed_prompts = decide_with_model([rule(id="run-failed"), model_rule], status="failed") + completed, _ = decide_with_model([rule(id="run-failed"), model_rule], status="completed") + + assert len(failed_prompts) == 1 + assert failed["model_evaluation"]["skipped_rule_ids"] == [] + assert failed["severity"] == "high" + assert failed["require_acknowledgment"] is True + assert (failed["sound"], failed["audience"]) == ("repeat", "group") + assert (completed["require_acknowledgment"], completed["sound"], completed["audience"]) == (True, "repeat", "group") + + +def test_a_model_rule_that_cannot_change_the_alert_is_still_skipped(): + model_rule = {**MODEL_RULE, "sound": "once"} + louder_rule = rule(id="run-failed", require_acknowledgment=True, sound="repeat") + + decision, prompts = decide_with_model([louder_rule, model_rule], status="failed") + + assert prompts == [] + assert decision["model_evaluation"]["skipped_rule_ids"] == ["customer-impact"] + assert decision["sound"] == "repeat" + + +# --- Runner ----------------------------------------------------------------------------- + + +def load_alert_creator(captured, decision, log_event): + """Exec the runner's real alert builder with its collaborators replaced.""" + runner = APP / "functions_workflow_runner.py" + names = {"_create_workflow_priority_alert", "_get_workflow_scope", "_get_workflow_group_id"} + tree = ast.parse(runner.read_text(encoding="utf-8-sig")) + functions = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names] + assert len(functions) == len(names) + namespace = { + "logging": logging, + "log_event": log_event, + "get_workflow_alert_signals": lambda: [], + "resolve_workflow_alert_config": lambda workflow: {"alert_mode": "rules"}, + "build_workflow_alert_facts": lambda *args: {}, + "_workflow_alert_rules_need_model_evaluation": lambda *args: False, + "_build_workflow_alert_model_evaluator": Mock(), + "evaluate_workflow_alert_rules": lambda *args, **kwargs: dict(decision), + "sanitize_workflow_alert_decision": lambda value: value, + "_record_workflow_alert_decision": Mock(), + "summarize_alert_decision": lambda value: "A rule matched.", + "normalize_alert_severity": lambda value: value, + "_normalize_workflow_alert_title_text": lambda value: value, + "_build_workflow_alert_target_from_conversation": lambda conversation, default_label: None, + "_select_preferred_workflow_alert_targets": lambda targets: targets, + "_build_workflow_alert_content": lambda *args, **kwargs: {"alert_title": "Title"}, + "_summarize_workflow_alert_text": lambda value: value, + "create_workflow_priority_notification": lambda **kwargs: captured.append(kwargs) or {"id": "n1"}, + } + exec(compile(ast.Module(body=functions, type_ignores=[]), str(runner), "exec"), namespace) + return namespace["_create_workflow_priority_alert"] + + +TEAM_DECISION = { + "should_alert": True, "severity": "high", "category": "alert", "delivery": "popup", "mode": "rules", + "matched_rules": [], "require_acknowledgment": True, "sound": "repeat", "size": "large", "audience": "group", +} + + +def test_a_group_audience_raises_one_shared_alert_for_a_group_workflow(): + captured = [] + create_alert = load_alert_creator(captured, TEAM_DECISION, Mock()) + workflow = {"id": "wf-2", "user_id": "owner", "name": "Team", "group_id": "group-7"} + + create_alert(workflow, {"id": "run-1", "status": "failed"}, {"id": "conversation-1"}) + + call = captured[0] + assert call["group_id"] == "group-7" + metadata = call["metadata"] + assert metadata["audience"] == "group" + assert metadata["owner_user_id"] == "owner" + assert metadata["require_acknowledgment"] is True + assert (metadata["sound"], metadata["size"]) == ("repeat", "large") + assert "group_id" not in metadata + + +def test_a_group_audience_falls_back_to_the_owner_outside_a_group(): + captured = [] + log_event = Mock() + create_alert = load_alert_creator(captured, TEAM_DECISION, log_event) + + create_alert({"id": "wf-1", "user_id": "owner", "name": "Mine"}, {"id": "run-1"}, {"id": "c1"}) + + call = captured[0] + assert call["group_id"] is None + assert call["user_id"] == "owner" + assert call["metadata"]["audience"] == "owner" + assert "owner_user_id" not in call["metadata"] + assert any("Group audience ignored" in str(args[0]) for args, _ in log_event.call_args_list) + + +# --- Notifications ---------------------------------------------------------------------- + + +def classify(query): + if "ARRAY_CONTAINS(@group_ids" in query: + return "team_pending" if "c.metadata.require_acknowledgment = true" in query else "team_unread" + if "c.metadata.require_acknowledgment = true" in query: + return "personal_pending" + if query.startswith("SELECT TOP @limit") and "c.user_id = @user_id" in query: + return "personal_unread" + if "WHERE c.user_id = @user_id" in query: + return "personal_all" + if "WHERE c.group_id = @group_id" in query: + return "group_all" + return "other" + + +class FakeContainer: + """An in-memory notifications container with ETag-conditional replace.""" + + def __init__(self): + self.docs = {} + self.pages = {} + self.calls = [] + self.writes = [] + self.before_replace = [] + self.version = 0 + + def put(self, document): + self.version += 1 + stored = deepcopy(document) + stored["_etag"] = f"etag-{self.version}" + self.docs[stored["id"]] = stored + return deepcopy(stored) + + def query_items(self, query, parameters=None, partition_key=None, enable_cross_partition_query=False, **kwargs): + values = {parameter["name"]: parameter["value"] for parameter in parameters or []} + self.calls.append({ + "kind": classify(query), "query": query, "parameters": values, + "partition_key": partition_key, "cross_partition": enable_cross_partition_query, + }) + if "WHERE c.id = @notification_id" in query: + document = self.docs.get(values["@notification_id"]) + return [deepcopy(document)] if document else [] + page = self.pages.get(classify(query), []) + if partition_key is not None: + # A single-partition read only ever sees that partition's documents. + page = [document for document in page if document.get("user_id") == partition_key] + return deepcopy(page) + + def replace_item(self, item, body, etag=None, match_condition=None, **kwargs): + if self.before_replace: + self.before_replace.pop(0)(self) + stored = self.docs[item] + if etag != stored["_etag"]: + raise cosmos_exceptions.CosmosAccessConditionFailedError(status_code=412, message="changed") + self.writes.append(item) + return self.put(body) + + def upsert_item(self, body): + self.writes.append(body["id"]) + return self.put(body) + + +MEMBERS = {"group-7": {"owner": "Owner", "member-1": "User", "member-2": "Admin"}} + + +def assert_group_role(user_id, group_id, allowed_roles=("Owner", "Admin")): + assert_group_role.calls.append((user_id, group_id, tuple(allowed_roles))) + if group_id not in MEMBERS: + raise LookupError("Group not found") + role = MEMBERS[group_id].get(user_id) + if not role: + raise PermissionError("User is not a member of this group") + if role.lower() not in {value.lower() for value in allowed_roles}: + raise PermissionError("Insufficient permissions for this group") + return role + + +def stub_modules(container, log_event): + replacements = {} + for name, values in { + "config": {"cosmos_notifications_container": container}, + "functions_appinsights": {"log_event": log_event}, + "functions_debug": {"debug_print": Mock()}, + "functions_group": { + "assert_group_role": assert_group_role, + "find_group_by_id": Mock(), + "get_user_groups": lambda user: [{"id": group_id} for group_id, members in MEMBERS.items() if user in members], + }, + "functions_public_workspaces": { + "find_public_workspace_by_id": Mock(), + "get_user_public_workspaces": lambda user: [], + }, + }.items(): + module = types.ModuleType(name) + module.__dict__.update(values) + replacements[name] = module + return replacements + + +def load_notifications(container): + assert_group_role.calls = [] + log_event = Mock() + spec = importlib.util.spec_from_file_location("test_ack_notifications", APP / "functions_notifications.py") + module = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, stub_modules(container, log_event)): + spec.loader.exec_module(module) + return module, log_event + + +def now(offset_hours=0): + return (datetime.now(timezone.utc) - timedelta(hours=offset_hours)).isoformat() + + +def personal_alert(alert_id="p1", require_ack=True, **overrides): + record = { + "id": alert_id, + "user_id": "owner", + "group_id": None, + "scope": "personal", + "notification_type": "workflow_priority_alert", + "title": "Ledger totals do not match", + "message": "Payments run found 3 mismatched rows", + "created_at": now(), + "read_by": [], + "dismissed_by": [], + "metadata": { + "priority": "critical", "category": "alert", "delivery": "popup", + "require_acknowledgment": require_ack, "sound": "repeat" if require_ack else "off", + "size": "large", "audience": "owner", "workflow_id": "wf-1", "workflow_name": "Payments", + }, + } + record.update(overrides) + return record + + +def team_alert(alert_id="t1", require_ack=True, **overrides): + record = { + "id": alert_id, + "user_id": None, + "group_id": "group-7", + "scope": "group", + "notification_type": "workflow_priority_alert", + "title": "Customer list exported to SECRET-PARTNER", + "message": "The run read the private HR folder and found 4 records", + "created_at": now(), + "read_by": ["member-2"], + "dismissed_by": [], + "link_url": "/chats?conversationId=workflow-conversation", + "link_context": {"workspace_type": "group", "conversation_id": "workflow-conversation"}, + "metadata": { + "priority": "high", "category": "failure", "delivery": "popup", + "require_acknowledgment": require_ack, "sound": "repeat" if require_ack else "off", + "size": "medium", "audience": "group", "owner_user_id": "owner", + "workflow_id": "wf-2", "workflow_name": "Nightly scan", "workflow_scope": "group", + "workflow_group_id": "group-7", "run_id": "run-9", "status": "failed", + "trigger_reason": "HIGH alert triggered by: Run failed, Leak found", + "matched_rules": [ + {"rule_id": "r1", "rule_name": "Run failed", "severity": "high", "condition_type": "run_status", + "reason": "Workflow run failed"}, + {"rule_id": "r2", "rule_name": "Leak found", "severity": "medium", + "condition_type": "model_evaluation", "reason": "The output names SECRET-PARTNER"}, + ], + "response_preview": "SECRET-PARTNER received 4 HR records", + "error": "Traceback: SECRET-PARTNER", + "event_title": "SECRET-PARTNER export", + "alert_title": "SECRET-PARTNER export", + "alert_summary": "4 HR records went to SECRET-PARTNER", + "alert_detail": "Full detail about SECRET-PARTNER", + "alert_enrichments": ["HR folder"], + "agent_name": "hr-agent", + "conversation_id": "workflow-conversation", + "link_targets": [ + {"label": "Open created conversation", "link_url": "/chats?conversationId=team-room", + "conversation_id": "team-room", + "link_context": {"workspace_type": "group", "group_id": "group-7", "chat_type": "group-multi-user", + "conversation_id": "team-room"}}, + {"label": "Open created conversation", "link_url": "/chats?conversationId=owner-private", + "conversation_id": "owner-private", + "link_context": {"workspace_type": "personal", "chat_type": "personal", + "conversation_id": "owner-private"}}, + {"label": "Open created conversation", "link_url": "/chats?conversationId=other-group-room", + "conversation_id": "other-group-room", + "link_context": {"workspace_type": "group", "group_id": "group-8", "chat_type": "group-multi-user", + "conversation_id": "other-group-room"}}, + {"label": "Open workflow", "link_url": "/chats?conversationId=workflow-conversation", + "conversation_id": "workflow-conversation", + "link_context": {"workspace_type": "group", "group_id": "group-7", "chat_type": "workflow", + "conversation_id": "workflow-conversation"}}, + ], + }, + } + record.update(overrides) + return record + + +def test_the_owner_acknowledges_a_personal_alert_and_it_records_who_and_when(): + container = FakeContainer() + container.put(personal_alert()) + notifications, log_event = load_notifications(container) + + status, alert = notifications.acknowledge_workflow_alert("p1", "owner", display_name=" Pat Owner ") + + assert status == "acknowledged" + stored = container.docs["p1"] + assert datetime.fromisoformat(stored["acknowledged_at"]) + assert stored["acknowledged_by"] == {"user_id": "owner", "display_name": "Pat Owner"} + assert stored["read_by"] == ["owner"] + assert alert["acknowledged"] is True + assert alert["acknowledged_by_name"] == "Pat Owner" + assert "acknowledged_by" not in alert + assert any("[NOTIFICATIONS] Workflow alert acknowledged." == args[0] for args, _ in log_event.call_args_list) + + +def test_someone_else_cannot_acknowledge_a_personal_alert_or_learn_it_exists(): + container = FakeContainer() + container.put(personal_alert()) + notifications, _ = load_notifications(container) + + other_user = notifications.acknowledge_workflow_alert("p1", "member-1") + missing = notifications.acknowledge_workflow_alert("missing", "owner") + blank = notifications.acknowledge_workflow_alert("", "owner") + + assert other_user == ("not_found", None) + assert missing == ("not_found", None) + assert blank == ("not_found", None) + assert "acknowledged_at" not in container.docs["p1"] + assert container.writes == [] + + +def test_any_current_member_acknowledges_a_team_alert_for_everyone(): + container = FakeContainer() + container.put(team_alert()) + notifications, _ = load_notifications(container) + + status, alert = notifications.acknowledge_workflow_alert("t1", "member-1", display_name="Jane") + + assert status == "acknowledged" + assert assert_group_role.calls == [("member-1", "group-7", ("Owner", "Admin", "DocumentManager", "User"))] + assert container.docs["t1"]["acknowledged_by"]["display_name"] == "Jane" + # The acknowledging member reads the reduced alert, and only their own read state. + assert alert["content_scope"] == "member" + assert alert["read_by"] == ["member-1"] + + +@pytest.mark.parametrize("user_id", ["stranger", "", None]) +def test_a_non_member_cannot_acknowledge_a_team_alert(user_id): + container = FakeContainer() + container.put(team_alert()) + notifications, _ = load_notifications(container) + + result = notifications.acknowledge_workflow_alert("t1", user_id) + + assert result == ("not_found", None) + assert "acknowledged_at" not in container.docs["t1"] + + +def test_a_team_alert_from_a_deleted_group_cannot_be_acknowledged(): + container = FakeContainer() + container.put(team_alert(group_id="group-gone")) + notifications, _ = load_notifications(container) + + result = notifications.acknowledge_workflow_alert("t1", "member-1") + + assert result == ("not_found", None) + + +def test_an_alert_that_needs_no_acknowledgment_is_refused(): + container = FakeContainer() + container.put(personal_alert(require_ack=False)) + container.put({"id": "chat", "user_id": "owner", "scope": "personal", "notification_type": "chat_response_complete"}) + notifications, _ = load_notifications(container) + + quiet_alert = notifications.acknowledge_workflow_alert("p1", "owner") + other_type = notifications.acknowledge_workflow_alert("chat", "owner") + + assert quiet_alert == ("not_required", None) + assert other_type == ("not_found", None) + assert container.writes == [] + + +def test_the_first_acknowledgment_wins(): + container = FakeContainer() + container.put(team_alert(acknowledged_at="2026-10-03T10:42:00+00:00", + acknowledged_by={"user_id": "member-2", "display_name": "Jane"})) + notifications, _ = load_notifications(container) + + status, alert = notifications.acknowledge_workflow_alert("t1", "member-1", display_name="Bob") + + assert status == "already_acknowledged" + assert container.docs["t1"]["acknowledged_by"] == {"user_id": "member-2", "display_name": "Jane"} + assert alert["acknowledged_by_name"] == "Jane" + # The caller's own read state is still recorded. + assert "member-1" in container.docs["t1"]["read_by"] + + +def test_an_acknowledgment_that_loses_a_race_keeps_the_winner(): + container = FakeContainer() + container.put(team_alert()) + notifications, _ = load_notifications(container) + + def jane_acknowledges_first(store): + winner = deepcopy(store.docs["t1"]) + winner["acknowledged_at"] = "2026-10-03T10:42:00+00:00" + winner["acknowledged_by"] = {"user_id": "member-2", "display_name": "Jane"} + store.put(winner) + + container.before_replace.append(jane_acknowledges_first) + + status, _ = notifications.acknowledge_workflow_alert("t1", "member-1", display_name="Bob") + + assert status == "already_acknowledged" + assert container.docs["t1"]["acknowledged_by"]["display_name"] == "Jane" + + +def test_a_stale_read_or_dismissal_cannot_undo_an_acknowledgment(): + container = FakeContainer() + container.put(team_alert()) + notifications, _ = load_notifications(container) + + def someone_acknowledges(store): + acknowledged = deepcopy(store.docs["t1"]) + acknowledged["acknowledged_at"] = "2026-10-03T10:42:00+00:00" + acknowledged["acknowledged_by"] = {"user_id": "member-2", "display_name": "Jane"} + store.put(acknowledged) + + container.before_replace.append(someone_acknowledges) + read = notifications.mark_notification_read("t1", "member-1") + container.before_replace.append(someone_acknowledges) + dismissed = notifications.dismiss_notification("t1", "owner") + + assert read is True + assert dismissed is True + stored = container.docs["t1"] + assert stored["acknowledged_at"] == "2026-10-03T10:42:00+00:00" + assert "member-1" in stored["read_by"] + assert "owner" in stored["dismissed_by"] + + +def test_reading_dismissing_and_marking_all_read_never_acknowledge(): + container = FakeContainer() + container.put(personal_alert()) + container.pages["personal_all"] = [deepcopy(container.docs["p1"])] + notifications, log_event = load_notifications(container) + + with patch.dict(sys.modules, stub_modules(container, log_event)): + marked_all = notifications.mark_all_read("owner") + read = notifications.mark_notification_read("p1", "owner") + dismissed = notifications.dismiss_notification("p1", "owner") + + assert marked_all == 1 + assert read is True + assert dismissed is True + stored = container.docs["p1"] + assert stored["read_by"] == ["owner"] + assert stored["dismissed_by"] == ["owner"] + assert "acknowledged_at" not in stored + + +def test_a_write_that_keeps_conflicting_gives_up_without_overwriting(): + container = FakeContainer() + container.put(personal_alert()) + notifications, _ = load_notifications(container) + + def touch(store): + store.put(deepcopy(store.docs["p1"])) + + container.before_replace.extend([touch, touch, touch]) + + read = notifications.mark_notification_read("p1", "owner") + + assert read is False + assert container.docs["p1"]["read_by"] == [] + + +def test_members_other_than_the_owner_never_receive_the_runs_output(): + container = FakeContainer() + notifications, _ = load_notifications(container) + + member = notifications._decorate_workflow_alert(team_alert(), "member-1") + owner = notifications._decorate_workflow_alert(team_alert(), "owner") + + assert member["content_scope"] == "member" + assert member["title"] == "High priority workflow alert: Nightly scan" + assert member["message"] == "Matched Run failed and Leak found. Open it for details." + assert "SECRET-PARTNER" not in repr(member) + assert "HR" not in repr({key: value for key, value in member.items() if key not in {"read_by", "dismissed_by"}}) + assert member["metadata"]["matched_rules"] == [ + {"rule_id": "r1", "rule_name": "Run failed", "severity": "high", "condition_type": "run_status"}, + {"rule_id": "r2", "rule_name": "Leak found", "severity": "medium", "condition_type": "model_evaluation"}, + ] + # Only the group conversation the run created in this group survives. + assert [target["conversation_id"] for target in member["metadata"]["link_targets"]] == ["team-room"] + assert member["link_url"] == "/chats?conversationId=team-room" + assert "owner_user_id" not in member["metadata"] + # A member sees only their own read state, never other members' ids. + assert member["read_by"] == [] + assert member["require_acknowledgment"] is True + assert (member["sound"], member["size"], member["audience"]) == ("repeat", "medium", "group") + + assert owner["content_scope"] == "full" + assert owner["metadata"]["alert_summary"] == "4 HR records went to SECRET-PARTNER" + assert owner["title"] == "Customer list exported to SECRET-PARTNER" + + +def test_a_team_alert_without_a_recorded_owner_is_reduced_for_everyone(): + alert = team_alert() + del alert["metadata"]["owner_user_id"] + notifications, _ = load_notifications(FakeContainer()) + + decorated = notifications._decorate_workflow_alert(alert, "owner") + + assert decorated["content_scope"] == "member" + + +def test_the_bell_list_reduces_team_alerts_for_members(): + container = FakeContainer() + container.pages["group_all"] = [team_alert()] + notifications, log_event = load_notifications(container) + + # The bell list imports its group and workspace lookups when it runs. + with patch.dict(sys.modules, stub_modules(container, log_event)): + page = notifications.get_user_notifications("member-1") + + assert [item["id"] for item in page["notifications"]] == ["t1"] + item = page["notifications"][0] + assert item["content_scope"] == "member" + assert "SECRET-PARTNER" not in repr(item) + assert item["is_read"] is False + + +def test_must_acknowledge_alerts_pop_up_whatever_their_age_or_read_state(): + container = FakeContainer() + old_and_read = personal_alert("p-old", created_at=now(72), read_by=["owner"], dismissed_by=["owner"]) + container.pages["personal_unread"] = [personal_alert("p-new", require_ack=False)] + container.pages["personal_pending"] = [ + old_and_read, + personal_alert("p-done", acknowledged_at="2026-10-01T00:00:00+00:00"), + ] + container.pages["team_unread"] = [team_alert("t-regular", require_ack=False, read_by=[])] + container.pages["team_pending"] = [team_alert("t-pending")] + notifications, _ = load_notifications(container) + + result = notifications.get_workflow_alert_popups("member-1", limit=10, since_hours=24) + + ids = [item["id"] for item in result["notifications"]] + assert sorted(ids) == ["t-pending", "t-regular"] + assert result["complete"] is True + + owner_result = notifications.get_workflow_alert_popups("owner", limit=10, since_hours=24) + owner_ids = [item["id"] for item in owner_result["notifications"]] + assert "p-old" in owner_ids and "p-new" in owner_ids + assert "p-done" not in owner_ids + old = next(item for item in owner_result["notifications"] if item["id"] == "p-old") + assert old["is_read"] is True and old["require_acknowledgment"] is True + + +def test_popup_reads_are_bounded_scoped_and_only_the_unread_reads_take_the_window(): + container = FakeContainer() + notifications, _ = load_notifications(container) + + notifications.get_workflow_alert_popups("member-1", limit=4, since_hours=24) + + by_kind = {call["kind"]: call for call in container.calls} + assert set(by_kind) == {"personal_unread", "personal_pending", "team_unread", "team_pending"} + for call in by_kind.values(): + assert call["parameters"]["@limit"] == 4 + assert "member-1" not in call["query"] + assert by_kind["personal_pending"]["partition_key"] == "member-1" + assert "@created_after" in by_kind["personal_unread"]["parameters"] + assert "@created_after" in by_kind["team_unread"]["parameters"] + assert "@created_after" not in by_kind["personal_pending"]["parameters"] + assert "@created_after" not in by_kind["team_pending"]["parameters"] + # Group alerts come only from the groups the server resolves for the caller. + assert by_kind["team_pending"]["parameters"]["@group_ids"] == ["group-7"] + assert by_kind["team_pending"]["cross_partition"] is True + + +def test_a_full_bounded_read_marks_the_answer_incomplete(): + container = FakeContainer() + container.pages["team_pending"] = [team_alert(f"t{index}") for index in range(2)] + notifications, _ = load_notifications(container) + + result = notifications.get_workflow_alert_popups("member-1", limit=2) + + assert result["complete"] is False + + +def test_a_user_in_no_group_runs_no_group_reads(): + container = FakeContainer() + notifications, _ = load_notifications(container) + + notifications.get_workflow_alert_popups("loner", limit=5) + + assert {call["kind"] for call in container.calls} == {"personal_unread", "personal_pending"} + + +def test_a_team_alert_is_created_in_the_group_scope(): + created = [] + container = FakeContainer() + container.create_item = lambda document: created.append(document) or document + notifications, _ = load_notifications(container) + + notifications.create_workflow_priority_notification( + user_id="owner", workflow_id="wf-2", workflow_name="Nightly scan", priority="high", + title="t", message="m", metadata={"run_id": "run-1"}, group_id=" group-7 ", + ) + notifications.create_workflow_priority_notification( + user_id="owner", workflow_id="wf-1", workflow_name="Mine", priority="high", title="t", message="m", + ) + + team, personal = created + assert (team["scope"], team["group_id"], team["user_id"]) == ("group", "group-7", None) + assert team["metadata"]["owner_user_id"] == "owner" + assert team["metadata"]["audience"] == "group" + assert (personal["scope"], personal["user_id"]) == ("personal", "owner") + assert "owner_user_id" not in personal["metadata"] + + +if __name__ == "__main__": + sys.exit(pytest.main([str(Path(__file__).resolve()), "-q"])) diff --git a/functional_tests/test_workflow_alert_bounded_query.py b/functional_tests/test_workflow_alert_bounded_query.py index 94e1d2004..11e2fb7db 100644 --- a/functional_tests/test_workflow_alert_bounded_query.py +++ b/functional_tests/test_workflow_alert_bounded_query.py @@ -49,7 +49,11 @@ def query_items(query, parameters=None, partition_key=None, **kwargs): "config": {"cosmos_notifications_container": types.SimpleNamespace(query_items=query_items)}, "functions_appinsights": {"log_event": Mock()}, "functions_debug": {"debug_print": debug}, - "functions_group": {"find_group_by_id": Mock(), "get_user_groups": lambda user: []}, + "functions_group": { + "assert_group_role": Mock(), + "find_group_by_id": Mock(), + "get_user_groups": lambda user: [], + }, "functions_public_workspaces": { "find_public_workspace_by_id": Mock(), "get_user_public_workspaces": lambda user: [], diff --git a/functional_tests/test_workflow_alert_client_parity.py b/functional_tests/test_workflow_alert_client_parity.py index ff614c80e..1cc265315 100644 --- a/functional_tests/test_workflow_alert_client_parity.py +++ b/functional_tests/test_workflow_alert_client_parity.py @@ -131,6 +131,14 @@ def rules_case(*rules, **fields): "scope_type_blank_defaults_to_final": (rules_case(rule(scope={"type": " "})), None, TASK_IDS), "unknown_rule_fields_accepted": (rules_case(rule(category="failure", extra={"kept": True})), None, TASK_IDS), "enabled_string_is_accepted": (rules_case(rule(enabled="no")), None, TASK_IDS), + "acknowledgment_valid": (rules_case(rule(require_acknowledgment=True, severity="high")), None, TASK_IDS), + "sound_once_valid": (rules_case(rule(sound="once", severity="medium")), None, TASK_IDS), + "size_large_valid": (rules_case(rule(size="large", severity="critical")), None, TASK_IDS), + "audience_group_valid_for_group_scope": (rules_case(rule(audience="group", severity="high")), None, TASK_IDS, "group"), + "audience_group_skips_unknown_scope": (rules_case(rule(audience="group", severity="high")), None, TASK_IDS), + "acknowledgment_repeat_valid": (rules_case(rule(require_acknowledgment=True, sound="repeat", severity="high")), None, TASK_IDS), + "string_flag_yes_repeat_valid": (rules_case(rule(require_acknowledgment=" Yes ", sound="repeat", severity="high")), None, TASK_IDS), + "string_flag_zero_sound_once_valid": (rules_case(rule(require_acknowledgment="0", sound="once", severity="high")), None, TASK_IDS), "python_whitespace_stripped_from_mode": ({"alert_mode": "\x1crules\x85", "alert_rules": [VALID]}, None, TASK_IDS), "feff_is_not_regex_whitespace": (rules_case(rule({"type": "text_match", "mode": "regex", "pattern": "(a+)\ufeff+"})), None, TASK_IDS), @@ -156,6 +164,14 @@ def rules_case(*rules, **fields): "R4_severity": (rules_case(rule(severity="urgent")), None, TASK_IDS), "R4_whitespace_severity": (rules_case(rule(severity=" ")), None, TASK_IDS), "R4_stored_rule_not_sent": ({}, {"alert_mode": "rules", "alert_rules": [rule(severity="urgent")]}, TASK_IDS), + "P1_sound_invalid": (rules_case(rule(sound="always")), None, TASK_IDS), + "P2_size_invalid": (rules_case(rule(size="giant")), None, TASK_IDS), + "P3_audience_invalid": (rules_case(rule(audience="company")), None, TASK_IDS), + "P4_group_audience_in_personal_scope": (rules_case(rule(audience="group")), None, TASK_IDS, "personal"), + "P5_acknowledgment_on_notify_only_delivery": (rules_case(rule(require_acknowledgment=True, severity="high", delivery="notify_only")), None, TASK_IDS), + "P5_sound_on_default_info_delivery": (rules_case(rule(sound="once", severity="info", delivery="default")), None, TASK_IDS), + "P5_size_on_default_low_delivery": (rules_case(rule(size="medium", severity="low", delivery="default")), None, TASK_IDS), + "P6_repeat_without_acknowledgment": (rules_case(rule(sound="repeat", severity="high")), None, TASK_IDS), # Refused: S1-S3. "S1_scope_type": (rules_case(rule(scope={"type": "everything"})), None, TASK_IDS), @@ -229,7 +245,9 @@ def rules_case(*rules, **fields): const [alerts, editor] = await Promise.all([import(lib('workflowAlerts')), import(lib('workflowEditor'))]); const output = { cases: {}, resolved: {}, described: [], editor: {} }; for (const [name, testCase] of Object.entries(input.cases)) { - output.cases[name] = alerts.workflowAlertErrors(testCase.sent, testCase.existing, testCase.task_ids); + output.cases[name] = alerts.workflowAlertErrors( + testCase.sent, testCase.existing, testCase.task_ids, testCase.workflow_scope ?? undefined, + ); } for (const [name, record] of Object.entries(input.resolved)) { const { legacy, ...config } = alerts.workflowAlertConfig(record); @@ -316,11 +334,11 @@ def _definition(**alert_fields): } -def _server(sent, existing, task_ids): +def _server(sent, existing, task_ids, workflow_scope=None): """(accepted, message) from the real normalizer; any other exception fails the test.""" try: ALERTS.normalize_workflow_alert_settings(copy.deepcopy(sent), existing_workflow=copy.deepcopy(existing), - task_ids=list(task_ids)) + task_ids=list(task_ids), workflow_scope=workflow_scope) except DEFINITIONS.WorkflowPublicValidationError as exc: return False, exc.public_message return True, "" @@ -328,8 +346,15 @@ def _server(sent, existing, task_ids): @pytest.fixture(scope="module") def client(): - cases = {name: {"sent": sent, "existing": existing, "task_ids": task_ids} - for name, (sent, existing, task_ids) in CASES.items()} + cases = { + name: { + "sent": item[0], + "existing": item[1], + "task_ids": item[2], + "workflow_scope": item[3] if len(item) > 3 else None, + } + for name, item in CASES.items() + } for name, pattern in REGEX_DIVERGENCE.items(): cases[name] = {"sent": rules_case(rule({"type": "text_match", "mode": "regex", "pattern": pattern})), "existing": None, "task_ids": TASK_IDS} @@ -352,8 +377,8 @@ def client(): @pytest.mark.parametrize("name", sorted(CASES)) def test_the_client_refuses_exactly_what_the_server_refuses(client, name): - sent, existing, task_ids = CASES[name] - accepted, message = _server(sent, existing, task_ids) + sent, existing, task_ids, *rest = CASES[name] + accepted, message = _server(sent, existing, task_ids, rest[0] if rest else None) errors = client["cases"][name] if accepted: @@ -393,9 +418,10 @@ def test_the_resolved_view_matches_resolve_workflow_alert_config(client): assert view["config"]["alert_priority"] == server["alert_priority"], name assert view["config"]["alert_rules"] == server["alert_rules"], name assert view["config"]["alert_evaluation"]["on_error"] == server["alert_evaluation"]["on_error"], name - assert view["summary"] == { + assert {key: view["summary"][key] for key in ("mode", "priority", "ruleCount")} == { "mode": server["alert_mode"], "priority": server["alert_priority"], "ruleCount": len(server["alert_rules"]), }, name + assert isinstance(view["summary"]["optionSummary"], list), name assert client["resolved"]["priority_only"]["legacy"] is True assert client["resolved"]["rules"]["legacy"] is False diff --git a/functional_tests/test_workflow_alert_error_redaction.py b/functional_tests/test_workflow_alert_error_redaction.py index 53b50d534..9f59a22b6 100644 --- a/functional_tests/test_workflow_alert_error_redaction.py +++ b/functional_tests/test_workflow_alert_error_redaction.py @@ -302,7 +302,11 @@ def query_items(query, **kwargs): "config": {"cosmos_notifications_container": types.SimpleNamespace(query_items=query_items)}, "functions_appinsights": {"log_event": Mock()}, "functions_debug": {"debug_print": logger}, - "functions_group": {"find_group_by_id": Mock(), "get_user_groups": lambda user: []}, + "functions_group": { + "assert_group_role": Mock(), + "find_group_by_id": Mock(), + "get_user_groups": lambda user: [], + }, "functions_public_workspaces": {"find_public_workspace_by_id": Mock(), "get_user_public_workspaces": lambda user: []}, }.items(): module = types.ModuleType(name) diff --git a/functional_tests/test_workflow_alert_notifications_import_cycle.py b/functional_tests/test_workflow_alert_notifications_import_cycle.py new file mode 100644 index 000000000..3a2f148d5 --- /dev/null +++ b/functional_tests/test_workflow_alert_notifications_import_cycle.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +# test_workflow_alert_notifications_import_cycle.py +""" +Functional test for the import cycle CodeQL reports on functions_notifications. +Version: 0.261.235 +Implemented in: 0.261.235 + +functions_notifications imports functions_group at module level, as it did before 0.261.235, and +from 0.261.235 also functions_group_workflow_policy and functions_workflow_alerts, for the +acknowledgment route and the workflow alert pop-up read. CodeQL's py/cyclic-import counts imports +made inside functions too, so it reports the chain functions_notifications -> functions_group -> +functions_settings -> content_screening.service -> functions_notifications. That chain closes only +through imports made when a function runs, so it can't fail while modules load. + +This test ensures it stays that way: no module-level import cycle passes through +functions_notifications, and the real modules load cold, offline, in each order the application +reaches them (the web routes, the scheduler's workflow runner, settings, screening and the group +helpers first), with and without -O, binding the real group checks. +""" + +import ast +import functools +import subprocess +import sys +from collections import deque +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "application" / "single_app" +TESTS = ROOT / "functional_tests" +sys.path.insert(0, str(TESTS)) + +from test_support.versioning import assert_app_version_at_least # noqa: E402 + + +NOTIFICATIONS = "functions_notifications" + + +def _module_name(path): + dotted = ".".join(path.relative_to(APP).with_suffix("").parts) + return dotted[: -len(".__init__")] if dotted.endswith(".__init__") else dotted + + +def _module_level_imports(module, tree): + """Dotted names imported while the module loads: its body, branches and classes, not functions.""" + package = module if (APP / Path(*module.split(".")) / "__init__.py").is_file() else module.rpartition(".")[0] + found = [] + + def resolve(node): + if isinstance(node, ast.Import): + return [alias.name for alias in node.names] + if not isinstance(node, ast.ImportFrom): + return [] + if node.level: + parts = package.split(".") if package else [] + base = ".".join(parts[: len(parts) - (node.level - 1)]) + prefix = f"{base}.{node.module}" if node.module else base + else: + prefix = node.module or "" + return [prefix] + [f"{prefix}.{alias.name}" for alias in node.names] + + def walk(nodes): + for node in nodes: + found.extend(resolve(node)) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + continue + children = [] + for field in ("body", "orelse", "finalbody"): + children.extend(getattr(node, field, None) or []) + for handler in getattr(node, "handlers", None) or []: + children.extend(handler.body) + walk(children) + + walk(tree.body) + return found + + +@functools.lru_cache(maxsize=1) +def _module_level_graph(): + modules = {_module_name(path): path for path in APP.rglob("*.py") if "__pycache__" not in path.parts} + edges = {} + for module, path in modules.items(): + try: + tree = ast.parse(path.read_text(encoding="utf-8", errors="replace")) + except SyntaxError: + continue + targets = set() + for name in _module_level_imports(module, tree): + # Importing a.b.c loads a and a.b first. + parts = name.split(".") + for end in range(1, len(parts) + 1): + candidate = ".".join(parts[:end]) + if candidate in modules and candidate != module: + targets.add(candidate) + edges[module] = targets + return edges + + +def _module_level_cycle_through(edges, start): + """A module-level import path from start back to itself, or None.""" + queue, seen = deque([[start]]), set() + while queue: + path = queue.popleft() + for target in sorted(edges.get(path[-1], ())): + if target == start: + return path + [start] + if target not in seen: + seen.add(target) + queue.append(path + [target]) + return None + + +def test_the_import_check_ships_in_this_version(): + assert_app_version_at_least("0.261.235") + + +def test_no_module_level_import_cycle_passes_through_notifications(): + edges = _module_level_graph() + # The modules this release imports at module level are still reached that way. + assert {"functions_group", "functions_group_workflow_policy", "functions_workflow_alerts"} <= edges[NOTIFICATIONS] + cycle = _module_level_cycle_through(edges, NOTIFICATIONS) + assert cycle is None, "A module-level import cycle passes through functions_notifications: " + " -> ".join(cycle) + + +# --------------------------------------------------------------------------- +# Cold imports +# --------------------------------------------------------------------------- + +PROBE = r''' +import importlib +import sys + +sys.path[:0] = sys.argv[1:3] +from test_support.offline_bootstrap import offline_app_imports + +with offline_app_imports() as environment: + for name in sys.argv[3].split(","): + importlib.import_module(name) + notifications = importlib.import_module("functions_notifications") + group = importlib.import_module("functions_group") + policy = importlib.import_module("functions_group_workflow_policy") + # The acknowledgment and the pop-up read check membership with the real group helpers. + if notifications.get_user_groups is not group.get_user_groups: + raise AssertionError("functions_notifications.get_user_groups is not functions_group's") + if notifications.assert_group_role is not group.assert_group_role: + raise AssertionError("functions_notifications.assert_group_role is not functions_group's") + if notifications.GROUP_WORKFLOW_MEMBER_ROLES is not policy.GROUP_WORKFLOW_MEMBER_ROLES: + raise AssertionError("functions_notifications has its own group workflow member roles") + for attribute in ("acknowledge_workflow_alert", "get_workflow_alert_popups", "create_workflow_priority_notification"): + if not callable(getattr(notifications, attribute, None)): + raise AssertionError(f"functions_notifications.{attribute} is missing") + if environment.network_attempts: + raise AssertionError("A cold import swallowed a network attempt") +print("PASS: workflow alert notification cold imports") +''' + +ORDERS = { + "notifications-first": "functions_notifications,functions_group,functions_workflow_alerts,content_screening.service", + "group-first": "functions_group,functions_notifications", + "settings-first": "functions_settings,functions_notifications", + "screening-first": "content_screening.service,functions_notifications", + "workflow-alerts-first": "functions_workflow_alerts,functions_notifications", + "scheduler-runner-first": "functions_workflow_runner", + "web-routes-first": "route_backend_notifications", +} + + +@pytest.mark.parametrize("optimized", [False, True], ids=["normal", "optimized"]) +@pytest.mark.parametrize("order", list(ORDERS.values()), ids=list(ORDERS)) +def test_the_modules_load_cold_offline_in_either_order(order, optimized): + command = [sys.executable, "-B", *(["-O"] if optimized else []), "-c", PROBE, str(APP), str(TESTS), order] + process = subprocess.run(command, capture_output=True, text=True, timeout=300, check=False) + assert process.returncode == 0, process.stdout[-8000:] + process.stderr[-8000:] + assert "PASS:" in process.stdout + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/functional_tests/test_workflow_alert_sound_assets.py b/functional_tests/test_workflow_alert_sound_assets.py new file mode 100644 index 000000000..8b58b5c01 --- /dev/null +++ b/functional_tests/test_workflow_alert_sound_assets.py @@ -0,0 +1,123 @@ +# test_workflow_alert_sound_assets.py +""" +Functional test for workflow alert sound assets. +Version: 0.261.235 +Implemented in: 0.261.235 + +This test ensures local workflow alert WAV assets are valid, distinct, and +byte-identical when regenerated. +""" + +import hashlib +import importlib.util +import shutil +import struct +import sys +import wave +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[1] +SCRIPT_PATH = ROOT_DIR / "scripts" / "generate_workflow_alert_sounds.py" +AUDIO_DIR = ROOT_DIR / "application" / "single_app" / "static" / "audio" / "workflow-alerts" +REGEN_DIR = ROOT_DIR / "functional_tests" / "__workflow_alert_sound_regen__" +EXPECTED_FILES = ["chime.wav", "urgent.wav", "alarm.wav"] +MIN_PEAK = 4000 +MAX_INT16 = 32767 + + +def load_generator(): + """Import the generator script directly from its file path.""" + spec = importlib.util.spec_from_file_location( + "generate_workflow_alert_sounds", + SCRIPT_PATH, + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def read_wave_details(path): + """Read WAV metadata, duration, samples, and hash.""" + with wave.open(str(path), "rb") as wav_file: + channels = wav_file.getnchannels() + sample_width = wav_file.getsampwidth() + frame_rate = wav_file.getframerate() + frame_count = wav_file.getnframes() + frames = wav_file.readframes(frame_count) + + samples = [sample[0] for sample in struct.iter_unpack("" in type_source + assert f"{SETTINGS_KEY}?: boolean;" in type_source + return True + + +def test_sanitized_settings_keep_non_secret_flag(): + """Sanitization strips secret-looking keys but keeps the workflow sound flag.""" + sanitize_settings_for_user = load_sanitize_settings_for_user() + + sanitized = sanitize_settings_for_user( + { + SETTINGS_KEY: False, + "azure_openai_key": "secret-value", + "nested": {SETTINGS_KEY: True, "client_secret": "hidden"}, + } + ) + + assert sanitized[SETTINGS_KEY] is False + assert "azure_openai_key" not in sanitized + assert sanitized["nested"][SETTINGS_KEY] is True + assert "client_secret" not in sanitized["nested"] + return True + + +def main(): + """Run all workflow alert sound setting checks.""" + tests = [ + test_default_setting_is_true, + test_admin_field_registry_has_workflow_switch, + test_classic_admin_pane_contains_toggle, + test_admin_route_defaults_and_parses_form_value, + test_v2_bootstrap_exposes_feature_flag_and_type, + test_sanitized_settings_keep_non_secret_flag, + ] + results = [] + for test in tests: + try: + test() + print(f"Passed: {test.__name__}") + results.append(True) + except Exception as exc: + print(f"FAILED {test.__name__}: {exc}") + import traceback + + traceback.print_exc() + results.append(False) + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + return all(results) + + +if __name__ == "__main__": + sys.exit(0 if main() else 1) diff --git a/scripts/generate_workflow_alert_sounds.py b/scripts/generate_workflow_alert_sounds.py new file mode 100644 index 000000000..ffa3a6d24 --- /dev/null +++ b/scripts/generate_workflow_alert_sounds.py @@ -0,0 +1,246 @@ +# generate_workflow_alert_sounds.py +"""Generate deterministic local WAV assets for workflow alert sounds.""" + +import argparse +import math +from pathlib import Path +import struct +import wave + + +SAMPLE_RATE = 22050 +SAMPLE_WIDTH = 2 +CHANNELS = 1 +TARGET_PEAK = 10 ** (-3 / 20) +MAX_INT16 = 32767 + + +def fade_envelope(local_time, duration, attack=0.008, release=0.012): + """Return a short attack/release envelope to avoid clicks.""" + if local_time < 0 or local_time >= duration: + return 0.0 + attack_level = min(1.0, local_time / attack) if attack > 0 else 1.0 + release_time = duration - local_time + release_level = min(1.0, release_time / release) if release > 0 else 1.0 + return min(attack_level, release_level) + + +def decaying_sine(local_time, duration, frequency, harmonic=0.18, decay=3.0): + """Return a gentle sine tone with one soft harmonic and decay.""" + phase = 2 * math.pi * frequency * local_time + body = math.sin(phase) + harmonic * math.sin(2 * phase) + return body * math.exp(-decay * local_time / duration) + + +def bright_beep(local_time, frequency): + """Return a square-ish bright beep from a few odd harmonics.""" + phase = 2 * math.pi * frequency * local_time + return ( + math.sin(phase) + + 0.33 * math.sin(3 * phase) + + 0.20 * math.sin(5 * phase) + ) + + +def render_track(duration, events): + """Render a list of tone events and normalize to the target peak.""" + frame_count = int(round(duration * SAMPLE_RATE)) + samples = [] + for index in range(frame_count): + current_time = index / SAMPLE_RATE + value = 0.0 + for event in events: + start = event["start"] + event_duration = event["duration"] + local_time = current_time - start + if 0 <= local_time < event_duration: + envelope = fade_envelope( + local_time, + event_duration, + event.get("attack", 0.008), + event.get("release", 0.012), + ) + if event["shape"] == "beep": + tone = bright_beep(local_time, event["frequency"]) + else: + tone = decaying_sine( + local_time, + event_duration, + event["frequency"], + event.get("harmonic", 0.18), + event.get("decay", 3.0), + ) + value += event.get("gain", 1.0) * envelope * tone + samples.append(value) + + peak = max(abs(sample) for sample in samples) or 1.0 + scale = TARGET_PEAK / peak + return [ + max(-MAX_INT16, min(MAX_INT16, int(round(sample * scale * MAX_INT16)))) + for sample in samples + ] + + +def write_wave(path, samples): + """Write 16-bit PCM mono WAV data.""" + path.parent.mkdir(parents=True, exist_ok=True) + frames = b"".join(struct.pack("} /> } /> Chat page

    } /> + } /> Another page

    } /> diff --git a/ui_tests/fixtures/workflow_editor.py b/ui_tests/fixtures/workflow_editor.py index 52254c456..1f07b3ae5 100644 --- a/ui_tests/fixtures/workflow_editor.py +++ b/ui_tests/fixtures/workflow_editor.py @@ -488,6 +488,7 @@ def __init__(self, page: Page): self.classic_visits = [] self.alert_refusals = [] self.settings_refusals = [] + self.workflow_alert_sounds_enabled = True # The administrator's Workflow Minimum Schedule Interval; one second is the setting's default. self.min_schedule_interval_seconds = 1 # Zones left out of the editor options' list, as when the browser reports a zone newer than @@ -725,6 +726,7 @@ def _bootstrap(self): "group": "automation", } payload["features"]["enable_group_workspaces"] = True + payload["features"]["enable_workflow_alert_sounds"] = self.workflow_alert_sounds_enabled payload["scope"]["active_group_id"] = self.active_group_id payload["scope"]["groups"] = [ {"id": GROUP_ID, "name": "Alpha Group"}, @@ -959,6 +961,7 @@ def _workflow_collection(self, route, entry, workflows, scope_type, group_id=Non alerts = normalize_workflow_alert_settings( entry.body, existing_workflow=existing, task_ids=[task.get("id") for task in definition["tasks"]], + workflow_scope=scope_type, ) except WorkflowPublicValidationError as exc: self.alert_refusals.append(exc.public_message) diff --git a/ui_tests/test_admin_workflow_settings_access.py b/ui_tests/test_admin_workflow_settings_access.py index 7376147bb..8912ba561 100644 --- a/ui_tests/test_admin_workflow_settings_access.py +++ b/ui_tests/test_admin_workflow_settings_access.py @@ -8,11 +8,13 @@ Updated in: 0.241.193 Updated in: 0.241.194 Updated in: 0.250.129 +Updated in: 0.261.235 This test ensures admins can see the dedicated Workspace settings sections with consistent app-role labels for workflow, group workflow assignment, group creation, public workspace creation, chat file uploads, workflow action limits, and workflow -task limits. It also verifies capacity guidance for higher workflow action limits. +task limits, and workflow alert sounds. It also verifies capacity guidance for +higher workflow action limits. """ import os @@ -71,12 +73,14 @@ def test_admin_workflow_settings_section(): expect(workflow_section).to_contain_text("Enable Cosmos DB Throughput automation in SimpleChat") expect(workflow_section).to_contain_text("Workflow Task Limit") expect(workflow_section).to_contain_text("Default is 50; supported range is 1-100") + expect(workflow_section).to_contain_text("Enable Workflow Alert Sounds") expect(workflow_section).to_contain_text("Enable Group Workflows") expect(workflow_section).to_contain_text("Require Group Assignment to Use Workflow") expect(workflow_section).to_contain_text("Group Workflow Assignments") expect(workflow_section).to_contain_text("Require Owner to Manage Group Agents, Actions and Workflows") expect(page.locator("#allow_user_workflows")).to_have_count(1) expect(page.locator("#require_member_of_workflow_user")).to_have_count(1) + expect(page.locator("#enable_workflow_alert_sounds")).to_have_count(1) workflow_action_limit = page.locator("#workflow_max_auto_invoke_attempts") expect(workflow_action_limit).to_have_count(1) expect(workflow_action_limit).to_have_attribute("type", "number") diff --git a/ui_tests/test_v2_notifications_bell.py b/ui_tests/test_v2_notifications_bell.py index ed0f1818d..b937a6bd6 100644 --- a/ui_tests/test_v2_notifications_bell.py +++ b/ui_tests/test_v2_notifications_bell.py @@ -326,6 +326,7 @@ def __init__(self, page): self.list_queries = [] self.read_calls = [] self.dismiss_calls = [] + self.acknowledge_calls = [] self.mark_all_calls = 0 self.conversation_reads = [] self.set_active_calls = [] @@ -420,6 +421,8 @@ def notification_route(self, route, method, path, query): self.answer_notice_change(route, path, unquote(match.group(1)), "read") elif method == "DELETE" and (match := re.fullmatch(r"/api/notifications/([^/]+)/dismiss", path)): self.answer_notice_change(route, path, unquote(match.group(1)), "dismiss") + elif method == "POST" and (match := re.fullmatch(r"/api/notifications/([^/]+)/acknowledge", path)): + self.answer_acknowledge(route, path, unquote(match.group(1))) else: return False return True @@ -536,6 +539,25 @@ def answer_notice_change(self, route, path, notice_id, change): record["is_dismissed"] = True route.fulfill(json={"success": True, "message": "Notification dismissed"}) + def answer_acknowledge(self, route, path, notice_id): + self.acknowledge_calls.append(notice_id) + record = self.find(notice_id) + if record is None: + self.expected_http_failures.add((path, 404)) + route.fulfill(status=404, json={"success": False, "error": "Alert not found."}) + return + record["acknowledged"] = True + record["acknowledged_at"] = iso(CLOCK_START) + record["acknowledged_by_name"] = "Riley Chen" + record["is_read"] = True + route.fulfill(json={ + "success": True, + "notification_id": notice_id, + "acknowledged_at": record["acknowledged_at"], + "acknowledged_by_name": record["acknowledged_by_name"], + "already_acknowledged": False, + }) + def answer_mark_all(self, route, path): self.mark_all_calls += 1 if "mark_all" in self.fail_next: @@ -958,6 +980,44 @@ def test_bell_sits_beside_the_brand_and_its_panel_lists_every_kind_of_notice(har expect(bell).to_have_attribute("aria-expanded", "false") +def test_workflow_alert_acknowledge_state_in_bell(harness): + pending = workflow_notice("n-ack", "Operations alarm", read=False) + pending.update({ + "require_acknowledgment": True, + "sound": "repeat", + "size": "large", + "audience": "group", + "acknowledged": False, + "acknowledged_at": None, + "acknowledged_by_name": None, + "content_scope": "full", + }) + done = workflow_notice("n-done", "Handled by teammate", read=True) + done.update({ + "require_acknowledgment": True, + "sound": "once", + "size": "small", + "audience": "group", + "acknowledged": True, + "acknowledged_at": iso(CLOCK_START), + "acknowledged_by_name": "Morgan Lee", + "content_scope": "full", + }) + harness.add(done, pending) + harness.open("/chat") + harness.open_panel() + + pending_row = harness.row("n-ack") + expect(pending_row.locator("[data-notification-ack-tag]")).to_have_text("Needs acknowledgment") + harness.action("n-ack", "acknowledge").click() + harness.wait_for(lambda: harness.acknowledge_calls == ["n-ack"], f"Ack calls: {harness.acknowledge_calls}") + harness.wait_for(lambda: harness.list_queries, "Acknowledge did not refresh the list.") + + expect(harness.row("n-done").locator("[data-notification-acknowledged]")).to_contain_text( + "Acknowledged by Morgan Lee at", + ) + + def test_collapsed_rail_shows_a_dot_and_the_panel_pages_through_the_list(harness): for number in range(25): harness.add(announcement_notice(f"n-{number:02d}", f"Notice {number:02d}", read=number >= 12)) diff --git a/ui_tests/test_v2_workflow_alert_notices.py b/ui_tests/test_v2_workflow_alert_notices.py index 2692134fd..2f7ed8135 100644 --- a/ui_tests/test_v2_workflow_alert_notices.py +++ b/ui_tests/test_v2_workflow_alert_notices.py @@ -415,6 +415,7 @@ def __init__(self, stylesheets): self.list_queries = [] self.read_calls = [] self.dismiss_calls = [] + self.acknowledge_calls = [] self.mark_all_calls = 0 self.settings_posts = [] self.errors = [] @@ -427,6 +428,7 @@ def __init__(self, stylesheets): self.alerts_failing = False # When not None, the count route answers this, as it answers 0 when it cannot count. self.count_override = None + self.sounds_enabled = True # While set, alert reads are answered as of their arrival, but only on release(). self.holding = False self.held = [] @@ -448,6 +450,14 @@ def mark_read(self, notice_id): record["is_read"] = True record["read_by"] = [USER_ID] + def acknowledge(self, notice_id, *, name="Riley Chen"): + record = self.find(notice_id) + record["acknowledged"] = True + record["acknowledged_at"] = iso(datetime.now(timezone.utc)) + record["acknowledged_by_name"] = name + record["is_read"] = True + record["read_by"] = [USER_ID] + def release(self): """Send the alert reads held back, each with the answer it would have had on arrival.""" self.holding = False @@ -514,6 +524,8 @@ def notification_route(self, route, method, path, query, tab): self.answer_change(route, unquote(match.group(1)), "read") elif method == "DELETE" and (match := re.fullmatch(r"/api/notifications/([^/]+)/dismiss", path)): self.answer_change(route, unquote(match.group(1)), "dismiss") + elif method == "POST" and (match := re.fullmatch(r"/api/notifications/([^/]+)/acknowledge", path)): + self.answer_acknowledge(route, unquote(match.group(1))) else: return False return True @@ -580,14 +592,25 @@ def answer_alerts(self, route, path, query, tab): items = [ record for record in self.notices if record["notification_type"] == WORKFLOW_ALERT_NOTIFICATION_TYPE - and not record["is_read"] and not record["is_dismissed"] + and ( + (record.get("require_acknowledgment") and not record.get("acknowledged")) + or (not record["is_read"] and not record["is_dismissed"]) + ) ] if not self.leaky: items = [record for record in items if record["metadata"].get("delivery") != "notify_only"] if since is not None: - items = [record for record in items if datetime.fromisoformat(record["created_at"]) >= since] + items = [ + record for record in items + if record.get("require_acknowledgment") and not record.get("acknowledged") + or datetime.fromisoformat(record["created_at"]) >= since + ] items.sort(key=lambda record: record["created_at"], reverse=True) - payload = workflow_alerts_payload(copy.deepcopy(items[:limit])) + payload = workflow_alerts_payload( + copy.deepcopy(items[:limit]), + complete=len(items) <= limit, + sounds_enabled=self.sounds_enabled, + ) if self.holding: self.held.append((route, payload)) return @@ -624,6 +647,26 @@ def answer_change(self, route, notice_id, change): record["dismissed_by"] = [USER_ID] route.fulfill(json={"success": True, "message": "Notification dismissed"}) + def answer_acknowledge(self, route, notice_id): + self.acknowledge_calls.append(notice_id) + record = self.find(notice_id) + if record is None: + route.fulfill(status=404, json={"success": False, "error": "Alert not found."}) + return + if not record.get("require_acknowledgment"): + route.fulfill(status=400, json={"success": False, "error": "This alert does not need acknowledgment."}) + return + already = bool(record.get("acknowledged")) + if not already: + self.acknowledge(notice_id) + route.fulfill(json={ + "success": True, + "notification_id": notice_id, + "acknowledged_at": record.get("acknowledged_at"), + "acknowledged_by_name": record.get("acknowledged_by_name"), + "already_acknowledged": already, + }) + def answer_mark_all(self, route): self.mark_all_calls += 1 unread = self.unread() @@ -1652,3 +1695,628 @@ def open_card(notice_id, **fields): tab.page.keyboard.press("Escape") expect(tab.card).to_have_count(0) assert server.read_calls == ["o1", "o2"] + + +# Must-acknowledge, sizes, sound and device switches -------------------------------------------- + + +def must_ack_alert(alert, notice_id, **fields): + fields.setdefault("require_acknowledgment", True) + fields.setdefault("minutes_ago", 60 * 48) + return alert(notice_id, **fields) + + +def test_must_ack_bypasses_claims_and_is_not_tuckable(tab, server, alert): + tab.open() + server.add(must_ack_alert(alert, "ack-1", title="Operations alarm")) + tab.poll() + expect(tab.notice).to_be_visible() + expect(tab.notice.locator("[data-workflow-alert-close]")).to_have_count(0) + expect(tab.notice.locator("[data-workflow-alert-ack-tag]")).to_have_text("Needs acknowledgment") + assert tab.claims() == {} + + tab.page.keyboard.press("Escape") + expect(tab.notice).to_be_visible() + tab.open_button.click() + expect(tab.card).to_be_visible() + tab.page.keyboard.press("Escape") + expect(tab.card).to_have_count(0) + expect(tab.notice).to_be_visible() + + tab.page.reload() + tab.js(SEED, {"path": "/work", "railCollapsed": False, "theme": "light"}) + tab.settle() + expect(tab.notice).to_contain_text("Operations alarm") + + +def test_acknowledge_and_open_acknowledge_then_retire(tab, server, alert): + tab.open() + server.add(must_ack_alert(alert, "ack-button", title="Ack from card")) + tab.poll() + tab.open_button.click() + tab.card.locator("[data-workflow-alert-acknowledge]").click() + tab.wait_for(lambda: server.acknowledge_calls == ["ack-button"], f"Ack calls: {server.acknowledge_calls}") + expect(tab.card).to_have_count(0) + + server.add(must_ack_alert(alert, "ack-open", title="Ack on open")) + tab.poll() + tab.open_button.click() + tab.card.locator("[data-workflow-alert-primary]").click() + tab.wait_for(lambda: "ack-open" in server.acknowledge_calls, f"Ack calls: {server.acknowledge_calls}") + tab.wait_for(lambda: tab.route_text() == "/chat?conversationId=conv-wf-ack-open", f"Open went to {tab.route_text()}.") + + +def test_mark_all_read_skips_must_ack_entries(tab, server, alert): + tab.open() + server.add( + must_ack_alert(alert, "ack-stays", workflow_id="wf-a", title="Needs owner"), + alert("ordinary", workflow_id="wf-b", priority="medium", title="Ordinary alert"), + ) + tab.poll() + tab.open_button.click() + tab.card.locator("[data-workflow-alert-mark-all]").click() + tab.wait_for(lambda: server.read_calls == ["ordinary"], f"Read calls: {server.read_calls}") + expect(tab.card).to_contain_text("Needs owner") + assert server.find("ack-stays")["is_read"] is False + + +def test_medium_and_large_sizes_open_directly_and_large_minimizes(tab, server, alert): + tab.open() + server.add(must_ack_alert(alert, "medium", title="Medium alert", size="medium")) + tab.poll() + expect(tab.card).to_be_visible() + expect(tab.notice).to_have_count(0) + tab.card.locator("[data-workflow-alert-acknowledge]").click() + tab.wait_for(lambda: "medium" in server.acknowledge_calls, "Medium alert was not acknowledged.") + expect(tab.card).to_have_count(0) + + server.add(must_ack_alert(alert, "large", title="Large alert", size="large", priority="critical")) + tab.poll() + expect(tab.card).to_be_visible() + # The marker sits on the dialog's backdrop, which always fills the window; measure the panel. + box = tab.card.locator(":scope > div").first.evaluate("""(panel) => { + const rect = panel.getBoundingClientRect(); + return {width: rect.width, height: rect.height, vw: innerWidth, vh: innerHeight}; + }""") + assert box["width"] >= box["vw"] - 40 and box["height"] >= box["vh"] - 40, box + tab.page.keyboard.press("Escape") + expect(tab.card).to_have_count(0) + expect(tab.notice).to_be_visible() + + +def install_audio_stub(tab, reject=False): + tab.js("""(reject) => { + window.__audioPlays = []; + HTMLMediaElement.prototype.play = function () { + window.__audioPlays.push({src: this.currentSrc || this.src, at: Date.now()}); + if (reject) { + const error = new DOMException('blocked', 'NotAllowedError'); + return Promise.reject(error); + } + return Promise.resolve(); + }; + }""", reject) + + +def test_sound_once_repeat_blocked_and_device_gates(tab, server, alert, now): + tab.open(clock=now) + install_audio_stub(tab) + server.add(alert("once", title="Chime once", sound="once")) + tab.poll() + tab.wait_for(lambda: len(tab.js("() => window.__audioPlays")) == 1, "Once sound did not play.") + assert len(tab.js("() => window.__audioPlays")) == 1 + tab.close_button.click() + tab.wait_tucked() + + server.add(must_ack_alert(alert, "repeat", title="Repeat", sound="repeat", priority="critical")) + tab.poll() + tab.run_for(5_100) + tab.wait_for(lambda: len(tab.js("() => window.__audioPlays")) >= 3, "Repeating sound did not repeat.") + tab.open_button.click() + tab.card.locator("[data-workflow-alert-acknowledge]").click() + tab.wait_for(lambda: "repeat" in server.acknowledge_calls, "Repeat alert was not acknowledged.") + expect(tab.card).to_have_count(0) + played = len(tab.js("() => window.__audioPlays")) + tab.run_for(5_100) + assert len(tab.js("() => window.__audioPlays")) == played + + install_audio_stub(tab, reject=True) + server.add(must_ack_alert(alert, "blocked", title="Blocked", sound="once")) + tab.poll() + expect(tab.notice.locator("[data-workflow-alert-enable-sound]")).to_be_visible() + install_audio_stub(tab, reject=False) + tab.notice.locator("[data-workflow-alert-enable-sound]").click() + tab.wait_for(lambda: len(tab.js("() => window.__audioPlays")) == 1, "Enable sound did not retry.") + + tab.js("() => localStorage.setItem('simplechat.workflowAlerts.playSounds', 'false')") + server.add(must_ack_alert(alert, "muted-device", title="Muted device", sound="once")) + tab.poll() + assert len(tab.js("() => window.__audioPlays")) == 1 + + tab.js("() => localStorage.setItem('simplechat.workflowAlerts.playSounds', 'true')") + server.sounds_enabled = False + server.add(must_ack_alert(alert, "muted-admin", title="Muted admin", sound="once")) + tab.poll() + assert len(tab.js("() => window.__audioPlays")) == 1 + + +def test_pending_must_ack_rereads_and_broadcast_retires(tab, server, alert, now): + tab.open(clock=now) + server.add(must_ack_alert(alert, "pending", title="Team alarm")) + tab.poll() + expect(tab.notice).to_be_visible() + server.acknowledge("pending", name="Morgan Lee") + tab.run_for(20_100) + tab.wait_for(lambda: tab.notice.count() == 0, "Pending must-ack alert did not retire after re-read.") + tab.wait_for(lambda: tab.state()["phase"] == "idle", "Store did not become idle after retirement.") + + tab.js(RECEIVE_DIRECT, [must_ack_alert(alert, "broadcasted", title="Acked in another tab")]) + expect(tab.notice).to_be_visible() + tab.js("""() => { + const channel = new BroadcastChannel('simplechat.workflowAlerts'); + channel.postMessage({type: 'acknowledged', ids: ['broadcasted']}); + channel.close(); + }""") + tab.wait_for(lambda: tab.notice.count() == 0, "Broadcast acknowledgment did not retire the alert.") + + +def test_member_scope_and_team_copy(tab, server, alert): + tab.open() + server.add(must_ack_alert( + alert, + "member", + title="Group workflow needs review", + summary="Build watcher matched the failed-runs rule.", + audience="group", + content_scope="member", + )) + tab.poll() + expect(tab.notice.locator("[data-workflow-alert-team]")).to_have_text("Sent to everyone in the group") + tab.open_button.click() + expect(tab.card.locator("[data-workflow-alert-team]")).to_have_text("Sent to everyone in the group") + + +def test_preferences_switches_persist_per_device(tab, server): + tab.open(path="/settings") + sounds = tab.page.get_by_label("Play alert sounds") + monitor = tab.page.get_by_label("Alert monitor") + expect(sounds).to_be_checked() + expect(monitor).not_to_be_checked() + sounds.click(force=True) + monitor.click(force=True) + assert tab.js("() => localStorage.getItem('simplechat.workflowAlerts.playSounds')") == "false" + assert tab.js("() => localStorage.getItem('simplechat.workflowAlerts.monitor')") == "true" + + +def test_monitor_mode_polls_and_reads_alerts_while_hidden(tab, server, alert, now): + tab.open(clock=now) + tab.hide() + before_count = tab.count_fetches() + tab.js("""() => { + localStorage.setItem('simplechat.workflowAlerts.monitor', 'true'); + window.dispatchEvent(new StorageEvent('storage', {key: 'simplechat.workflowAlerts.monitor', newValue: 'true'})); + }""") + server.add(alert("hidden-monitor", title="Hidden monitor alert")) + tab.run_for(30_100) + tab.wait_for(lambda: tab.count_fetches() > before_count, "Monitor mode did not poll while hidden.") + tab.wait_for(lambda: tab.alert_queries(), "Monitor mode did not read workflow alerts on its poll.") + + +BATCH_TOKEN = "() => window.WorkflowAlertHarness.stores.workflowAlert.useWorkflowAlertStore.getState().batchToken" + + +def plays(tab): + return len(tab.js("() => window.__audioPlays")) + + +def test_clicks_and_key_presses_do_not_replay_a_repeating_sound(tab, server, alert, now): + tab.open(clock=now) + install_audio_stub(tab) + server.add(must_ack_alert(alert, "steady", title="Steady alarm", sound="repeat", priority="critical")) + tab.poll() + tab.wait_for(lambda: plays(tab) == 1, "The repeating sound did not start.") + + # Typing elsewhere on the page while the alert waits must not add beeps. + for key in ("a", "b", "Shift", "Enter"): + tab.page.keyboard.press(key) + tab.page.mouse.click(700, 600) + tab.page.wait_for_timeout(300) + assert plays(tab) == 1 + + tab.run_for(5_100) + tab.wait_for(lambda: plays(tab) == 2, "The repeating sound did not keep its five-second beat.") + + +def test_play_once_sounds_once_however_often_a_pending_alert_is_read(tab, server, alert, now): + tab.open(clock=now) + install_audio_stub(tab) + server.add(must_ack_alert(alert, "once-pending", title="Chime once, keep waiting", sound="once")) + tab.poll() + tab.wait_for(lambda: plays(tab) == 1, "The one-off sound did not play.") + reads = len(tab.alert_queries()) + + tab.run_for(20_100) + tab.wait_for(lambda: len(tab.alert_queries()) > reads, "The pending alert was not read again.") + tab.run_for(20_100) + tab.wait_for(lambda: len(tab.alert_queries()) > reads + 1, "The pending alert was not read a third time.") + tab.page.wait_for_timeout(200) + assert plays(tab) == 1 + + +def test_a_showing_alert_is_not_presented_again_by_each_read(tab, server, alert, now): + tab.open(clock=now) + server.add(must_ack_alert(alert, "on-screen", title="Already on screen")) + tab.poll() + expect(tab.notice).to_be_visible() + token = tab.js(BATCH_TOKEN) + reads = len(tab.alert_queries()) + + tab.run_for(20_100) + tab.wait_for(lambda: len(tab.alert_queries()) > reads, "The pending alert was not read again.") + tab.settle() + # Presenting it again would replay the notice's bell swing on every read. + assert tab.js(BATCH_TOKEN) == token + assert tab.state()["entries"] == [["on-screen"]] + + +def test_enable_sound_is_a_control_of_its_own_beside_the_notice(tab, server, alert): + tab.open() + install_audio_stub(tab, reject=True) + server.add(must_ack_alert(alert, "refused", title="Sound refused", sound="once")) + tab.poll() + enable = tab.notice.locator("[data-workflow-alert-enable-sound]") + expect(enable).to_be_visible() + assert enable.evaluate("(element) => element.tagName") == "BUTTON" + expect(tab.open_button.locator("[data-workflow-alert-enable-sound]")).to_have_count(0) + + install_audio_stub(tab, reject=False) + enable.click() + tab.wait_for(lambda: plays(tab) == 1, "Enable sound did not retry the refused sound.") + expect(tab.card).to_have_count(0) + + +def test_acknowledging_from_the_bell_stops_the_alert_in_this_tab(tab, server, alert, now): + tab.open(clock=now) + install_audio_stub(tab) + server.add(must_ack_alert(alert, "from-bell", title="Acknowledge me from the bell", sound="repeat")) + tab.poll() + expect(tab.notice).to_be_visible() + tab.wait_for(lambda: plays(tab) == 1, "The repeating sound did not start.") + + tab.bell.click() + expect(tab.panel).to_be_visible() + tab.panel.locator('[data-notification-action="acknowledge"]').first.click() + tab.wait_for(lambda: server.acknowledge_calls == ["from-bell"], f"Ack calls: {server.acknowledge_calls}") + tab.page.keyboard.press("Escape") + expect(tab.panel).to_have_count(0) + + tab.wait_for(lambda: tab.notice.count() == 0, "The notice stayed after the bell acknowledged it.") + played = plays(tab) + tab.run_for(5_100) + tab.page.wait_for_timeout(200) + assert plays(tab) == played + + +def test_reading_a_pending_alert_in_the_bell_keeps_its_notice_and_sound(tab, server, alert, now): + tab.open(clock=now) + install_audio_stub(tab) + server.add(must_ack_alert(alert, "read-in-bell", title="Read but not acknowledged", sound="repeat")) + tab.poll() + expect(tab.notice).to_be_visible() + tab.wait_for(lambda: plays(tab) == 1, "The repeating sound did not start.") + + tab.bell.click() + expect(tab.panel).to_be_visible() + tab.panel.locator('[data-notification-action="read"]').first.click() + tab.wait_for(lambda: server.read_calls == ["read-in-bell"], f"Read calls: {server.read_calls}") + tab.page.keyboard.press("Escape") + expect(tab.panel).to_have_count(0) + + # Reading doesn't acknowledge it, so the notice and its sound stay. + expect(tab.notice).to_be_visible() + assert server.acknowledge_calls == [] + played = plays(tab) + tab.run_for(5_100) + tab.wait_for(lambda: plays(tab) > played, "The repeating sound stopped when the alert was only read.") + + +def test_opening_a_pending_alert_from_the_bell_acknowledges_it(tab, server, alert): + tab.open() + server.add(must_ack_alert(alert, "open-in-bell", title="Open me from the bell")) + tab.poll() + tab.bell.click() + expect(tab.panel).to_be_visible() + tab.panel.locator('[data-notification-action="open"]').first.click() + tab.wait_for(lambda: server.acknowledge_calls == ["open-in-bell"], f"Ack calls: {server.acknowledge_calls}") + assert server.read_calls == [] + + +def test_a_large_alert_is_set_in_large_type_and_open_stays_the_one_green_action(tab, server, alert): + tab.open() + server.add(must_ack_alert(alert, "wall", title="Wall display alarm", size="large", priority="critical")) + tab.poll() + expect(tab.card).to_be_visible() + title_size = tab.card.locator("[data-workflow-alert-title]").evaluate( + "(element) => parseFloat(getComputedStyle(element).fontSize)" + ) + assert title_size >= 28, title_size + + # Open acknowledges too, so it keeps the one green button; Acknowledge is secondary beside it. + background = "(element) => getComputedStyle(element).backgroundColor" + acknowledge_fill = tab.card.locator("[data-workflow-alert-acknowledge]").evaluate(background) + open_fill = tab.card.locator("[data-workflow-alert-primary]").evaluate(background) + assert acknowledge_fill != open_fill, (acknowledge_fill, open_fill) + + +BROADCAST_ACKNOWLEDGED = r""" +(ids) => { + const channel = new BroadcastChannel('simplechat.workflowAlerts'); + channel.postMessage({type: 'acknowledged', ids}); + channel.close(); +} +""" + + +def sources(tab): + return [item["src"] for item in tab.js("() => window.__audioPlays")] + + +def test_a_waiting_alert_acknowledged_elsewhere_stops_its_sound(tab, server, alert, now): + tab.open(clock=now) + install_audio_stub(tab) + tab.hide() + server.add(must_ack_alert(alert, "queued", title="Waiting alarm", sound="repeat", priority="critical")) + tab.poll() + state = tab.state() + assert state["suspended"] and state["queue"] == ["queued"] and state["entries"] == [], state + # It sounds while it waits for the tab to be seen. + tab.wait_for(lambda: plays(tab) == 1, "The waiting alert did not sound.") + + server.acknowledge("queued", name="Morgan Lee") + reads = len(tab.alert_queries()) + tab.run_for(20_100) + tab.wait_for(lambda: len(tab.alert_queries()) > reads, "The pending alert was not read again.") + tab.wait_for(lambda: tab.state()["queue"] == [], f"The acknowledged alert still waits: {tab.state()}") + played = plays(tab) + tab.run_for(5_100) + tab.page.wait_for_timeout(200) + assert plays(tab) == played + + +def test_a_refused_sound_is_not_retried_for_an_alert_acknowledged_elsewhere(tab, server, alert, now): + tab.open(clock=now) + install_audio_stub(tab, reject=True) + server.add(must_ack_alert(alert, "refused-acked", title="Refused alarm", sound="repeat")) + tab.poll() + expect(tab.notice.locator("[data-workflow-alert-enable-sound]")).to_be_visible() + + tab.js(BROADCAST_ACKNOWLEDGED, ["refused-acked"]) + tab.wait_for(lambda: tab.notice.count() == 0, "The broadcast acknowledgment did not retire the alert.") + + # Sound is allowed now, but nothing is left to sound for. + install_audio_stub(tab) + tab.page.mouse.click(700, 600) + tab.page.keyboard.press("Shift") + tab.page.wait_for_timeout(200) + tab.run_for(5_100) + tab.page.wait_for_timeout(200) + assert plays(tab) == 0 + + +def test_opening_one_alert_leaves_the_others_waiting_for_acknowledgment(tab, server, alert, now): + tab.open(clock=now) + install_audio_stub(tab) + server.add( + must_ack_alert(alert, "first", workflow_id="wf-a", title="First alarm", sound="repeat", + priority="critical", minutes_ago=5), + must_ack_alert(alert, "second", workflow_id="wf-b", title="Second alarm", sound="repeat", + priority="high", minutes_ago=6), + ) + tab.poll() + expect(tab.notice).to_contain_text("First alarm") + tab.open_button.click() + expect(tab.card).to_be_visible() + tab.card.locator("[data-workflow-alert-primary]").click() + tab.wait_for(lambda: server.acknowledge_calls == ["first"], f"Ack calls: {server.acknowledge_calls}") + tab.wait_for(lambda: tab.route_text() == "/chat?conversationId=conv-wf-a", f"Open went to {tab.route_text()}.") + + # The other one stays up, in the notice, and keeps sounding. + expect(tab.card).to_have_count(0) + expect(tab.notice).to_contain_text("Second alarm") + assert tab.state()["entries"] == [["second"]] + played = plays(tab) + tab.run_for(5_100) + tab.wait_for(lambda: plays(tab) > played, "The alert still waiting stopped sounding.") + + +def test_acknowledging_one_alert_keeps_the_next_one_sounding(tab, server, alert, now): + tab.open(clock=now) + install_audio_stub(tab) + server.add( + must_ack_alert(alert, "loud", workflow_id="wf-a", title="Loud alarm", sound="repeat", + priority="critical", minutes_ago=5), + must_ack_alert(alert, "quiet", workflow_id="wf-b", title="Quiet alarm", sound="repeat", + priority="low", minutes_ago=6), + ) + tab.poll() + tab.wait_for(lambda: plays(tab) == 1, "The repeating sound did not start.") + assert sources(tab)[-1].endswith("/alarm.wav"), sources(tab) + + tab.open_button.click() + tab.card.locator("[data-workflow-alert-acknowledge]").click() + tab.wait_for(lambda: server.acknowledge_calls == ["loud"], f"Ack calls: {server.acknowledge_calls}") + expect(tab.card).to_contain_text("Quiet alarm") + + # The quieter alert still waits, so the sound carries on in its tone, without a pause. + played = plays(tab) + tab.run_for(5_100) + tab.wait_for(lambda: plays(tab) > played, "The alert still waiting stopped sounding.") + assert sources(tab)[-1].endswith("/chime.wav"), sources(tab) + + +def test_two_tabs_play_a_one_off_chime_once_between_them(tab, another_tab, server, alert): + tab.open() + other = another_tab("tab-2").open() + install_audio_stub(tab) + install_audio_stub(other) + server.add(must_ack_alert(alert, "shared-once", title="Chime once for the browser", sound="once")) + + first, second = tab.start_poll(), other.start_poll() + tab.finish_poll(first) + other.finish_poll(second) + # Both tabs show an alert that needs acknowledgment; only one of them chimes. + expect(tab.notice).to_be_visible() + expect(other.notice).to_be_visible() + tab.wait_for(lambda: plays(tab) + plays(other) >= 1, "Neither tab chimed.") + tab.page.wait_for_timeout(500) + assert (plays(tab), plays(other)) in {(1, 0), (0, 1)}, (plays(tab), plays(other)) + assert "shared-once" in tab.js( + "() => JSON.parse(localStorage.getItem('simplechat.workflowAlerts.soundedOnce') || '{}')" + ) + + +def test_an_alert_waiting_for_acknowledgment_leads_whatever_its_priority(tab, server, alert): + tab.open() + server.add( + alert("ordinary-critical", workflow_id="wf-a", priority="critical", title="Ordinary critical alert"), + must_ack_alert(alert, "low-ack", workflow_id="wf-b", priority="low", title="Low, needs acknowledgment"), + ) + tab.poll() + expect(tab.notice).to_contain_text("Low, needs acknowledgment") + assert tab.state()["entries"] == [["low-ack"], ["ordinary-critical"]] + + +def test_a_notice_waiting_for_acknowledgment_takes_its_own_room_in_the_rail(tab, server, alert): + tab.open() + server.add(must_ack_alert(alert, "roomy", title="Needs its own room")) + tab.poll() + expect(tab.notice).to_be_visible() + expect(tab.page.locator("[data-workflow-alert-slot]")).to_have_attribute("data-in-flow", "true") + + group_workspaces = tab.page.get_by_role("link", name="Group Workspaces", exact=True) + notice_bottom = tab.notice.evaluate("(notice) => notice.getBoundingClientRect().bottom") + item_top = group_workspaces.evaluate("(link) => link.getBoundingClientRect().top") + assert item_top >= notice_bottom, (item_top, notice_bottom) + + # Focus on the item under it is in plain sight, and the notice stays. + group_workspaces.focus() + tab.page.wait_for_timeout(200) + expect(group_workspaces).to_be_focused() + expect(tab.notice).to_be_visible() + expect(tab.notice).to_have_attribute("data-stepped-aside", "false") + + +PLACE_UNDER_NOTICE = r""" +() => { + const rect = document.querySelector('[data-workflow-alert-notice]').getBoundingClientRect(); + const button = document.createElement('button'); + button.type = 'button'; + button.id = 'under-the-notice'; + button.textContent = 'Under the notice'; + Object.assign(button.style, { + position: 'fixed', left: `${rect.left + 8}px`, top: `${rect.top + 8}px`, + width: '120px', height: '32px', zIndex: '1', + }); + document.body.append(button); +} +""" + + +def test_a_flyout_waiting_for_acknowledgment_steps_aside_from_focus_it_covers(tab, server, alert): + tab.open(rail_collapsed=True) + server.add(must_ack_alert(alert, "flyout", title="Flyout alarm")) + tab.poll() + expect(tab.notice).to_have_attribute("data-placement", "flyout") + expect(tab.page.locator("[data-workflow-alert-slot]")).to_have_attribute("data-in-flow", "false") + + tab.js(PLACE_UNDER_NOTICE) + tab.focus("#under-the-notice") + expect(tab.notice).to_have_attribute("data-stepped-aside", "true") + expect(tab.notice).to_be_hidden() + assert tab.state()["phase"] == "notice", tab.state() + + # Focus somewhere it doesn't cover brings it back. + tab.my_workspace.focus() + expect(tab.notice).to_have_attribute("data-stepped-aside", "false") + expect(tab.notice).to_be_visible() + + +def test_a_flyout_that_stepped_aside_shows_the_next_alert_that_needs_acknowledgment(tab, server, alert): + tab.open(rail_collapsed=True) + server.add( + must_ack_alert(alert, "first-ack", workflow_id="wf-a", priority="high", title="First alarm"), + alert("ordinary", workflow_id="wf-b", priority="critical", title="Ordinary critical alert"), + ) + tab.poll() + expect(tab.notice).to_contain_text("First alarm") + tab.js(PLACE_UNDER_NOTICE) + tab.focus("#under-the-notice") + expect(tab.notice).to_have_attribute("data-stepped-aside", "true") + + # Acknowledged in another tab: the ordinary alert leads, and focus moves somewhere it doesn't cover. + server.acknowledge("first-ack", name="Morgan Lee") + tab.js(BROADCAST_ACKNOWLEDGED, ["first-ack"]) + expect(tab.notice).to_contain_text("Ordinary critical alert") + tab.poll() + tab.my_workspace.focus() + tab.page.wait_for_timeout(200) + + # The next alert that needs acknowledgment is in plain sight, not hidden by what focus did before. + server.add(must_ack_alert(alert, "second-ack", workflow_id="wf-c", priority="high", title="Second alarm")) + tab.poll() + expect(tab.notice).to_contain_text("Second alarm") + expect(tab.notice).to_have_attribute("data-stepped-aside", "false") + expect(tab.notice).to_be_visible() + + # It steps aside when focus moves under it; a new alert taking the lead is shown all the same. + tab.focus("#under-the-notice") + expect(tab.notice).to_have_attribute("data-stepped-aside", "true") + server.add(must_ack_alert(alert, "third-ack", workflow_id="wf-d", priority="critical", title="Third alarm")) + tab.poll() + expect(tab.notice).to_contain_text("Third alarm") + expect(tab.notice).to_have_attribute("data-stepped-aside", "false") + expect(tab.notice).to_be_visible() + + +@pytest.mark.parametrize("must_acknowledge", [False, True], ids=["ordinary", "must-acknowledge"]) +def test_alerts_that_arrive_together_chime_once_in_the_loudest_tone(tab, server, alert, now, must_acknowledge): + tab.open(clock=now) + install_audio_stub(tab) + + def make(notice_id, **fields): + return must_ack_alert(alert, notice_id, **fields) if must_acknowledge else alert(notice_id, **fields) + + # Newest first, as the server lists them: the quiet alert would otherwise ask first. + server.add( + make("older-critical", workflow_id="wf-a", priority="critical", title="Critical chime", sound="once", + minutes_ago=5), + make("newer-low", workflow_id="wf-b", priority="low", title="Low chime", sound="once", minutes_ago=1), + ) + tab.poll() + expect(tab.notice).to_contain_text("Critical chime") + tab.wait_for(lambda: plays(tab) >= 1, "Neither alert chimed.") + tab.page.wait_for_timeout(300) + assert len(sources(tab)) == 1 and sources(tab)[0].endswith("/alarm.wav"), sources(tab) + sounded = tab.js("() => JSON.parse(localStorage.getItem('simplechat.workflowAlerts.soundedOnce') || '{}')") + assert {"older-critical", "newer-low"} <= set(sounded), sounded + + +def test_enable_sound_retries_refused_chimes_as_one_in_the_loudest_tone(tab, server, alert): + tab.open() + install_audio_stub(tab, reject=True) + server.add( + must_ack_alert(alert, "refused-critical", workflow_id="wf-a", priority="critical", title="Refused critical", + sound="once", minutes_ago=5), + must_ack_alert(alert, "refused-low", workflow_id="wf-b", priority="low", title="Refused low", sound="once", + minutes_ago=1), + ) + tab.poll() + enable = tab.notice.locator("[data-workflow-alert-enable-sound]") + expect(enable).to_be_visible() + + install_audio_stub(tab) + enable.click() + tab.wait_for(lambda: plays(tab) >= 1, "Enable sound did not retry the refused chimes.") + tab.page.wait_for_timeout(300) + assert len(sources(tab)) == 1 and sources(tab)[0].endswith("/alarm.wav"), sources(tab) + expect(enable).to_have_count(0) diff --git a/ui_tests/test_v2_workflow_alerts.py b/ui_tests/test_v2_workflow_alerts.py index e861fe8d5..3ffa0db4a 100644 --- a/ui_tests/test_v2_workflow_alerts.py +++ b/ui_tests/test_v2_workflow_alerts.py @@ -381,6 +381,126 @@ def test_readers_keep_the_summary(workflow_ui): assert not ui.workflow_writes +@pytest.mark.parametrize("scope", SCOPES) +def test_popup_option_controls_save_only_non_default_keys(workflow_ui, scope): + """Pop-up options are always visible, coupled, and only non-default choices are posted.""" + ui, page = workflow_ui, workflow_ui.page + open_workflows(ui, scope) + dialog = edit(ui, NAMES[scope]) + region = alerts_region(page) + field(region, "When to alert").select_option("rules") + region.get_by_role("button", name="Add alert rule", exact=True).click() + row = rule_rows(page).nth(0) + + expect(row.get_by_text("Pop-up options", exact=True)).to_be_visible() + expect(row.get_by_text("These apply when the rule pops up.", exact=True)).to_be_visible() + expect(field(row, "Require acknowledgment")).not_to_be_checked() + expect(row.get_by_text("The alert keeps coming back, on every page and device, until someone acknowledges it.")).to_be_visible() + expect(field(row, "Sound")).to_have_value("off") + expect(field(row, "Size")).to_have_value("small") + if scope == "group": + expect(field(row, "Who gets it")).to_have_value("owner") + else: + expect(field(row, "Who gets it")).to_have_count(0) + + field(row, "Sound").select_option("repeat") + expect(field(row, "Require acknowledgment")).to_be_checked() + field(row, "Require acknowledgment").uncheck() + expect(field(row, "Sound")).to_have_value("once") + field(row, "Sound").select_option("off") + field(row, "Size").select_option("large") + field(row, "Size").select_option("small") + field(row, "Require acknowledgment").check() + if scope == "group": + field(row, "Who gets it").select_option("group") + + body = save(ui, dialog).body + sent_rule = body["alert_rules"][0] + assert sent_rule["require_acknowledgment"] is True + assert "sound" not in sent_rule + assert "size" not in sent_rule + if scope == "group": + assert sent_rule["audience"] == "group" + else: + assert "audience" not in sent_rule + + +@pytest.mark.parametrize("scope", SCOPES) +def test_stored_popup_options_load_back_into_the_editor(workflow_ui, scope): + ui, page = workflow_ui, workflow_ui.page + fields = {"require_acknowledgment": True, "sound": "repeat", "size": "large"} + if scope == "group": + fields["audience"] = "group" + target(ui, scope).update( + alert_mode="rules", alert_priority="none", alert_evaluation={"on_error": "skip"}, + alert_rules=[rule("Escalation", {"type": "no_output"}, severity="high", **fields)], + ) + open_workflows(ui, scope) + edit(ui, NAMES[scope]) + row = rule_rows(page).nth(0) + expect(field(row, "Require acknowledgment")).to_be_checked() + expect(field(row, "Sound")).to_have_value("repeat") + expect(field(row, "Size")).to_have_value("large") + if scope == "group": + expect(field(row, "Who gets it")).to_have_value("group") + + +def test_sound_admin_note_is_shown_when_sounds_are_disabled(workflow_ui): + ui, page = workflow_ui, workflow_ui.page + ui.workflow_alert_sounds_enabled = False + open_workflows(ui, "personal") + edit(ui, NAMES["personal"]) + region = alerts_region(page) + field(region, "When to alert").select_option("rules") + region.get_by_role("button", name="Add alert rule", exact=True).click() + expect(rule_rows(page).nth(0).get_by_text( + "Your administrator has turned off workflow alert sounds. This setting is kept, but no sound plays.", + exact=True, + )).to_be_visible() + + +@pytest.mark.parametrize("fields,message,scope", [ + ({"sound": "always"}, "Alert rule 1 sound must be off, once or repeat.", "personal"), + ({"size": "giant"}, "Alert rule 1 size must be small, medium or large.", "personal"), + ({"audience": "company"}, "Alert rule 1 audience must be owner or group.", "group"), + ({"audience": "group"}, "Alert rule 1 can alert the whole group only in a group workflow.", "personal"), + ({"require_acknowledgment": True, "severity": "info", "delivery": "default"}, + "Alert rule 1 must pop up to require acknowledgment, play a sound or change its size.", "personal"), + ({"sound": "repeat", "severity": "high"}, "Alert rule 1 can repeat its sound only when it requires acknowledgment.", "personal"), +]) +def test_popup_option_validation_blocks_invalid_rules_before_saving(workflow_ui, fields, message, scope): + ui, page = workflow_ui, workflow_ui.page + target(ui, scope).update( + alert_mode="rules", alert_priority="none", alert_evaluation={"on_error": "skip"}, + alert_rules=[rule("Option review", {"type": "no_output"}, **fields)], + ) + open_workflows(ui, scope) + dialog = edit(ui, NAMES[scope]) + row = rule_rows(page).nth(0) + expect(row.get_by_role("alert")).to_have_text(message) + requests = len(ui.requests) + dialog.get_by_role("button", name="Save workflow", exact=True).click() + expect(dialog.get_by_role("alert").filter(has_text=message).first).to_be_visible() + assert not [entry for entry in ui.requests[requests:] if entry.method == "POST"] + + +def test_read_only_summary_includes_popup_options(workflow_ui): + ui, page = workflow_ui, workflow_ui.page + ui.group_can_manage = False + target(ui, "group").update( + alert_mode="rules", alert_priority="none", alert_evaluation={"on_error": "skip"}, + alert_rules=[rule( + "Escalation", {"type": "no_output"}, severity="critical", + require_acknowledgment=True, sound="repeat", size="large", audience="group", + )], + ) + open_workflows(ui, "group") + page.get_by_role("button", name="View Group review workflow", exact=True).click() + region = alerts_region(page) + expect(region.get_by_text("Must be acknowledged · Repeats sound · Large · Everyone in the group", exact=True)).to_be_visible() + expect(region.get_by_role("combobox")).to_have_count(0) + + @pytest.mark.parametrize("scope", SCOPES) def test_version_one_workflows_edit_alerts_natively_without_a_classic_link(workflow_ui, scope): """M6B D3: native editing replaces M6's classic alerts link; a V2 save converts the workflow.""" diff --git a/ui_tests/test_workflow_alert_classic_acknowledgment.py b/ui_tests/test_workflow_alert_classic_acknowledgment.py new file mode 100644 index 000000000..d938637b2 --- /dev/null +++ b/ui_tests/test_workflow_alert_classic_acknowledgment.py @@ -0,0 +1,806 @@ +# test_workflow_alert_classic_acknowledgment.py +""" +UI test for classic workflow alert acknowledgment. +Version: 0.261.235 +Implemented in: 0.261.235 + +This test runs the real classic workflow alert JavaScript against a minimal +offline page and validates must-acknowledge modal, banner, sizing, sound, and +notification-center behavior. +""" + +import json +import re +from pathlib import Path +from urllib.parse import urlparse + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "application" / "single_app" +BASE_TEMPLATE = APP / "templates" / "base.html" +STATIC = APP / "static" + + +def _require_playwright(): + return pytest.importorskip("playwright.sync_api", reason="Install Playwright to run this UI test.") + + +def _extract_workflow_alert_markup(): + """Extract the production modal and banner markup from base.html.""" + source = BASE_TEMPLATE.read_text(encoding="utf-8") + start = source.index("