diff --git a/application/single_app/config.py b/application/single_app/config.py index 5393dc20a..6abbbcb76 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.224" +VERSION = "0.250.225" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_group_workflows.py b/application/single_app/functions_group_workflows.py index 1563c9b34..1163459c8 100644 --- a/application/single_app/functions_group_workflows.py +++ b/application/single_app/functions_group_workflows.py @@ -38,6 +38,7 @@ _normalize_bool, _normalize_document_action_config, _normalize_schedule, + _normalize_task_document_action_config, _normalize_text, _normalize_workflow_error_handling, _normalize_workflow_tasks, @@ -54,12 +55,9 @@ WORKFLOW_CONVERSATION_ACCESS_ERROR = 'Workflow conversation not found or access denied.' -def _normalize_group_document_action_config(group_id, workflow_data, existing_workflow=None, allow_empty_file_sync_targets=False): - action_config = _normalize_document_action_config( - workflow_data, - existing_workflow=existing_workflow, - allow_empty_file_sync_targets=allow_empty_file_sync_targets, - ) +def _apply_group_document_action_scope(group_id, action_config): + """Force a normalized document action to stay inside the owning group workspace.""" + action_config = action_config if isinstance(action_config, dict) else {'type': 'none'} if action_config.get('type') == 'none': return action_config @@ -69,6 +67,15 @@ def _normalize_group_document_action_config(group_id, workflow_data, existing_wo return action_config +def _normalize_group_document_action_config(group_id, workflow_data, existing_workflow=None, allow_empty_file_sync_targets=False): + action_config = _normalize_document_action_config( + workflow_data, + existing_workflow=existing_workflow, + allow_empty_file_sync_targets=allow_empty_file_sync_targets, + ) + return _apply_group_document_action_scope(group_id, action_config) + + def _normalize_group_workflow_conversation_id(group_id, workflow_data, existing_workflow=None): existing_workflow = existing_workflow if isinstance(existing_workflow, dict) else {} conversation_id = _normalize_text( @@ -440,6 +447,20 @@ def save_group_workflow(group_id, workflow_data, actor_user_id, user_info=None): workflow_name = _normalize_text(workflow_data.get('name'), 'Workflow name', required=True) description = _normalize_text(workflow_data.get('description'), 'Description') + file_sync = _normalize_file_sync_config( + actor_user_id, + group_id, + workflow_data, + existing_workflow=existing_workflow, + user_info=user_info, + ) + allow_empty_file_sync_targets = bool(file_sync.get('enabled') and file_sync.get('use_changed_documents')) + document_action = _normalize_group_document_action_config( + group_id, + workflow_data, + existing_workflow=existing_workflow, + allow_empty_file_sync_targets=allow_empty_file_sync_targets, + ) tasks = _normalize_workflow_tasks( workflow_data, existing_workflow=existing_workflow, @@ -450,6 +471,15 @@ def save_group_workflow(group_id, workflow_data, actor_user_id, user_info=None): settings=settings, ), max_tasks=get_workflow_max_tasks(settings), + task_document_action_normalizer=lambda action_payload: _apply_group_document_action_scope( + group_id, + _normalize_task_document_action_config( + action_payload, + allow_empty_file_sync_targets=allow_empty_file_sync_targets, + settings=settings, + ), + ), + default_document_action=document_action, ) task_prompt = _normalize_text( workflow_data.get('task_prompt') or (tasks[0].get('instructions') if tasks else ''), @@ -495,20 +525,6 @@ def save_group_workflow(group_id, workflow_data, actor_user_id, user_info=None): workflow_data.get('chat_capabilities_enabled', default_chat_capabilities_enabled), default=default_chat_capabilities_enabled, ) - file_sync = _normalize_file_sync_config( - actor_user_id, - group_id, - workflow_data, - existing_workflow=existing_workflow, - user_info=user_info, - ) - allow_empty_file_sync_targets = bool(file_sync.get('enabled') and file_sync.get('use_changed_documents')) - document_action = _normalize_group_document_action_config( - group_id, - workflow_data, - existing_workflow=existing_workflow, - allow_empty_file_sync_targets=allow_empty_file_sync_targets, - ) if trigger_type == 'file_sync': if not file_sync.get('enabled'): raise ValueError('Monitor File Sync Changes workflows require File Sync before run.') @@ -806,4 +822,4 @@ def delete_group_workflow(group_id, workflow_id): level=logging.WARNING, ) - return True \ No newline at end of file + return True diff --git a/application/single_app/functions_personal_workflows.py b/application/single_app/functions_personal_workflows.py index 3aaa75083..a0b459fb8 100644 --- a/application/single_app/functions_personal_workflows.py +++ b/application/single_app/functions_personal_workflows.py @@ -163,6 +163,8 @@ def _normalize_workflow_tasks( existing_workflow=None, task_runner_normalizer=None, max_tasks=WORKFLOW_MAX_TASKS, + task_document_action_normalizer=None, + default_document_action=None, ): workflow_data = workflow_data if isinstance(workflow_data, dict) else {} existing_workflow = existing_workflow if isinstance(existing_workflow, dict) else {} @@ -214,14 +216,27 @@ def _normalize_workflow_tasks( else: raise ValueError(f'Workflow task {index + 1} runner could not be authorized.') - normalized_tasks.append({ + normalized_task = { 'id': task_id, 'type': task_type, 'name': name, 'instructions': instructions, 'order': index + 1, 'runner': runner, - }) + } + + if callable(task_document_action_normalizer): + raw_document_action = raw_task.get('document_action') + if not isinstance(raw_document_action, dict) and index == 0: + # Workflows saved before per-task documents kept a single workflow-level + # action that only ever executed on the first task. + raw_document_action = default_document_action + try: + normalized_task['document_action'] = task_document_action_normalizer(raw_document_action) + except ValueError as exc: + raise ValueError(f'Workflow task {index + 1} ({name}): {exc}') from exc + + normalized_tasks.append(normalized_task) return normalized_tasks @@ -286,6 +301,37 @@ def _normalize_document_action_config(workflow_data, existing_workflow=None, all ) +def _normalize_task_document_action_config(action_payload, allow_empty_file_sync_targets=False, settings=None): + """Normalize a single workflow task's document action payload.""" + source_settings = settings if isinstance(settings, dict) else get_settings() + action_payload = action_payload if isinstance(action_payload, dict) else {'type': 'none'} + max_documents_by_type = get_document_action_max_documents_by_type( + DOCUMENT_ACTION_CONTEXT_WORKFLOW, + settings=source_settings, + ) + allowed_action_types = get_enabled_document_action_types(settings=source_settings) + + if allow_empty_file_sync_targets: + action_type = str(action_payload.get('type') or '').strip().lower() + document_ids = action_payload.get('document_ids') if isinstance(action_payload.get('document_ids'), list) else [] + if action_type == DOCUMENT_ACTION_TYPE_ANALYZE and not document_ids: + placeholder_payload = dict(action_payload) + placeholder_payload['document_ids'] = ['__dynamic_file_sync_document__'] + normalized_action = normalize_document_action_config( + action_payload=placeholder_payload, + max_documents_by_type=max_documents_by_type, + allowed_action_types=allowed_action_types, + ) + normalized_action['document_ids'] = [] + return normalized_action + + return normalize_document_action_config( + action_payload=action_payload, + max_documents_by_type=max_documents_by_type, + allowed_action_types=allowed_action_types, + ) + + def _normalize_file_sync_config(user_id, workflow_data, existing_workflow=None): workflow_data = workflow_data if isinstance(workflow_data, dict) else {} existing_workflow = existing_workflow if isinstance(existing_workflow, dict) else {} @@ -683,6 +729,13 @@ def save_personal_workflow(user_id, workflow_data, actor_user_id=None): workflow_name = _normalize_text(workflow_data.get('name'), 'Workflow name', required=True) description = _normalize_text(workflow_data.get('description'), 'Description') + file_sync = _normalize_file_sync_config(user_id, workflow_data, existing_workflow=existing_workflow) + allow_empty_file_sync_targets = bool(file_sync.get('enabled') and file_sync.get('use_changed_documents')) + document_action = _normalize_document_action_config( + workflow_data, + existing_workflow=existing_workflow, + allow_empty_file_sync_targets=allow_empty_file_sync_targets, + ) tasks = _normalize_workflow_tasks( workflow_data, existing_workflow=existing_workflow, @@ -692,6 +745,12 @@ def save_personal_workflow(user_id, workflow_data, actor_user_id=None): settings=settings, ), max_tasks=get_workflow_max_tasks(settings), + task_document_action_normalizer=lambda action_payload: _normalize_task_document_action_config( + action_payload, + allow_empty_file_sync_targets=allow_empty_file_sync_targets, + settings=settings, + ), + default_document_action=document_action, ) task_prompt = _normalize_text( workflow_data.get('task_prompt') or (tasks[0].get('instructions') if tasks else ''), @@ -737,13 +796,6 @@ def save_personal_workflow(user_id, workflow_data, actor_user_id=None): workflow_data.get('chat_capabilities_enabled', default_chat_capabilities_enabled), default=default_chat_capabilities_enabled, ) - file_sync = _normalize_file_sync_config(user_id, workflow_data, existing_workflow=existing_workflow) - allow_empty_file_sync_targets = bool(file_sync.get('enabled') and file_sync.get('use_changed_documents')) - document_action = _normalize_document_action_config( - workflow_data, - existing_workflow=existing_workflow, - allow_empty_file_sync_targets=allow_empty_file_sync_targets, - ) if trigger_type == 'file_sync': if not file_sync.get('enabled'): raise ValueError('Monitor File Sync Changes workflows require File Sync before run.') diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index 485621773..1ee221e2d 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -7030,6 +7030,25 @@ def _format_workflow_file_sync_context(file_sync_result): return '\n'.join(lines) +def _apply_file_sync_changed_documents_to_action(action_config, changed_document_ids, group_ids, public_workspace_ids): + """Point an analyze document action at the documents File Sync just changed.""" + action_config = action_config if isinstance(action_config, dict) else {} + if action_config.get('type') != DOCUMENT_ACTION_TYPE_ANALYZE: + return None + + if not changed_document_ids: + return {'type': DOCUMENT_ACTION_TYPE_NONE} + + updated_action_config = dict(action_config) + updated_action_config.update({ + 'document_ids': list(changed_document_ids), + 'doc_scope': 'all', + 'active_group_ids': list(group_ids), + 'active_public_workspace_id': list(public_workspace_ids), + }) + return updated_action_config + + def _apply_file_sync_context_to_workflow(workflow, file_sync_result): if not isinstance(file_sync_result, dict) or not file_sync_result.get('enabled'): return workflow @@ -7044,15 +7063,6 @@ def _apply_file_sync_context_to_workflow(workflow, file_sync_result): if not config.get('use_changed_documents'): return prepared_workflow - action_config = _get_document_action_config(workflow) - if action_config.get('type') != DOCUMENT_ACTION_TYPE_ANALYZE: - return prepared_workflow - - if not changed_document_ids: - prepared_workflow['document_action'] = {'type': DOCUMENT_ACTION_TYPE_NONE} - prepared_workflow['analyze'] = build_analyze_config(prepared_workflow['document_action']) - return prepared_workflow - group_ids = [] public_workspace_ids = [] for source_config in config.get('sources') or []: @@ -7063,20 +7073,50 @@ def _apply_file_sync_context_to_workflow(workflow, file_sync_result): elif scope_type == 'public' and scope_id and scope_id not in public_workspace_ids: public_workspace_ids.append(scope_id) - updated_action_config = dict(action_config) - updated_action_config.update({ - 'document_ids': changed_document_ids, - 'doc_scope': 'all', - 'active_group_ids': group_ids, - 'active_public_workspace_id': public_workspace_ids, - }) + updated_tasks = [] + tasks_changed = False + for raw_task in prepared_workflow.get('tasks') or []: + task = dict(raw_task or {}) + if isinstance(task.get('document_action'), dict): + updated_task_action = _apply_file_sync_changed_documents_to_action( + task.get('document_action'), + changed_document_ids, + group_ids, + public_workspace_ids, + ) + if updated_task_action is not None: + task['document_action'] = updated_task_action + tasks_changed = True + updated_tasks.append(task) + if tasks_changed: + prepared_workflow['tasks'] = updated_tasks + + updated_action_config = _apply_file_sync_changed_documents_to_action( + _get_document_action_config(workflow), + changed_document_ids, + group_ids, + public_workspace_ids, + ) + if updated_action_config is None: + return prepared_workflow + prepared_workflow['document_action'] = updated_action_config prepared_workflow['analyze'] = build_analyze_config(updated_action_config) return prepared_workflow -def _document_run_item_id(run_id, document_id): +def _get_workflow_active_task(workflow): + active_task = (workflow or {}).get('active_task') + return active_task if isinstance(active_task, dict) else {} + + +def _document_run_item_id(run_id, document_id, task_id=''): normalized_document_id = re.sub(r'[^a-zA-Z0-9._-]+', '-', str(document_id or '').strip()) + normalized_task_id = re.sub(r'[^a-zA-Z0-9._-]+', '-', str(task_id or '').strip()) + if normalized_task_id: + # Per-task document actions let two tasks target the same document in one run, + # so the run item has to be scoped to the task or statuses overwrite each other. + return f'{run_id}:task:{normalized_task_id}:document:{normalized_document_id}' return f'{run_id}:document:{normalized_document_id}' @@ -7100,8 +7140,10 @@ def _save_document_run_item(workflow, run_id, document_id, status, *, file_sync_ now_iso = _utc_now_iso() file_sync_document = _file_sync_document_details(file_sync_result or {}, document_id) + active_task = _get_workflow_active_task(workflow) + task_id = str(active_task.get('id') or '').strip() item = { - 'id': _document_run_item_id(run_id, document_id), + 'id': _document_run_item_id(run_id, document_id, task_id=task_id), 'type': 'workflow_run_item', 'item_type': 'document', 'run_id': run_id, @@ -7109,6 +7151,8 @@ def _save_document_run_item(workflow, run_id, document_id, status, *, file_sync_ 'workflow_id': workflow.get('id'), 'group_id': _get_workflow_group_id(workflow) or None, 'workflow_name': workflow.get('name'), + 'task_id': task_id or None, + 'task_name': str(active_task.get('name') or '').strip() or None, 'document_id': document_id, 'label': _document_label_from_file_sync(file_sync_result or {}, document_id), 'source': 'file_sync' if file_sync_document else 'workflow', @@ -9045,11 +9089,30 @@ def _truncate_workflow_task_context(value, max_chars=WORKFLOW_TASK_CONTEXT_MAX_C ) +def _resolve_workflow_task_document_action(workflow, task, include_document_action=False): + """Resolve the document action a workflow task should execute with. + + Tasks own their document action. Workflows saved before per-task documents have no + task-level key, so the workflow-level action still applies to the first task only. + """ + task = task if isinstance(task, dict) else {} + if isinstance(task.get('document_action'), dict): + return _get_document_action_config({'document_action': task.get('document_action')}) + if include_document_action: + return _get_document_action_config(workflow) + return {'type': DOCUMENT_ACTION_TYPE_NONE} + + def _build_workflow_task_execution_workflow(workflow, task, previous_reply='', include_document_action=False): task = task if isinstance(task, dict) else {} prepared_workflow = dict(workflow or {}) task_instructions = str(task.get('instructions') or '').strip() file_sync_context = str(workflow.get('file_sync_prompt_context') or '').strip() + task_document_action = _resolve_workflow_task_document_action( + workflow, + task, + include_document_action=include_document_action, + ) if include_document_action and file_sync_context: task_instructions = ( f'{task_instructions}\n\n' @@ -9071,9 +9134,8 @@ def _build_workflow_task_execution_workflow(workflow, task, previous_reply='', i 'name': str(task.get('name') or '').strip(), 'order': int(task.get('order') or 0), } - if not include_document_action: - prepared_workflow['document_action'] = {'type': DOCUMENT_ACTION_TYPE_NONE} - prepared_workflow['analyze'] = build_analyze_config(prepared_workflow['document_action']) + prepared_workflow['document_action'] = dict(task_document_action) + prepared_workflow['analyze'] = build_analyze_config(prepared_workflow['document_action']) return prepared_workflow @@ -9465,13 +9527,6 @@ def raise_if_cancelled(): title=str(task.get('name') or f'Task {task_index + 1}'), status='running', ) - prepared_workflow = _build_workflow_task_execution_workflow( - workflow, - task, - previous_reply=previous_reply, - include_document_action=task_index == 0, - ) - task_result = None task_error = '' attempt_count = 0 @@ -9479,6 +9534,14 @@ def raise_if_cancelled(): raise_if_cancelled() attempt_count = attempt_index + 1 try: + # Built inside the attempt so an invalid task document action fails this + # task through the normal retry and error strategy instead of the whole run. + prepared_workflow = _build_workflow_task_execution_workflow( + workflow, + task, + previous_reply=previous_reply, + include_document_action=task_index == 0, + ) attempt_workflow, runner_audit = _resolve_workflow_task_runner( prepared_workflow, task, diff --git a/application/single_app/route_backend_workflows.py b/application/single_app/route_backend_workflows.py index f16b1b3d4..9acd4cee5 100644 --- a/application/single_app/route_backend_workflows.py +++ b/application/single_app/route_backend_workflows.py @@ -34,7 +34,7 @@ ) from functions_group import require_active_group from functions_public_workspaces import require_active_public_workspace -from functions_document_actions import DOCUMENT_ACTION_TYPE_ANALYZE, build_analyze_config +from functions_document_actions import DOCUMENT_ACTION_TYPE_ANALYZE, DOCUMENT_ACTION_TYPE_NONE, build_analyze_config from functions_thoughts import get_thoughts_for_message from functions_workflow_activity import build_workflow_activity_snapshot from functions_msgraph_pending_actions import list_msgraph_pending_actions, sanitize_msgraph_pending_action_for_client @@ -324,18 +324,62 @@ def _collect_group_workflow_file_sync_sources(user_id, group_id, settings=None): ] +def _force_group_document_action_scope(action_config, group_id): + """Keep a resumed group workflow document action inside the owning group workspace.""" + action_config = dict(action_config) if isinstance(action_config, dict) else {'type': DOCUMENT_ACTION_TYPE_NONE} + if action_config.get('type') == DOCUMENT_ACTION_TYPE_NONE: + return action_config + + action_config['doc_scope'] = 'group' + action_config['active_group_ids'] = [group_id] + action_config['active_public_workspace_id'] = [] + return action_config + + +def _narrow_analyze_action_to_documents(action_config, document_ids, group_ids, public_workspace_ids): + """Point an analyze action at a specific document set, or disable it when empty.""" + action_config = action_config if isinstance(action_config, dict) else {} + if action_config.get('type') != DOCUMENT_ACTION_TYPE_ANALYZE: + return None + if not document_ids: + return {'type': DOCUMENT_ACTION_TYPE_NONE} + + narrowed_action = dict(action_config) + narrowed_action.update({ + 'document_ids': list(document_ids), + 'doc_scope': 'all', + 'active_group_ids': group_ids or list(action_config.get('active_group_ids') or []), + 'active_public_workspace_id': public_workspace_ids or list(action_config.get('active_public_workspace_id') or []), + }) + return narrowed_action + + def _build_resume_failed_workflow(workflow, failed_items): action_config = workflow.get('document_action') if isinstance(workflow.get('document_action'), dict) else {} - if action_config.get('type') != DOCUMENT_ACTION_TYPE_ANALYZE: + tasks = workflow.get('tasks') if isinstance(workflow.get('tasks'), list) else [] + task_analyze_ids = { + _normalize_identifier(task.get('id')) + for task in tasks + if isinstance(task, dict) + and isinstance(task.get('document_action'), dict) + and task['document_action'].get('type') == DOCUMENT_ACTION_TYPE_ANALYZE + } + if action_config.get('type') != DOCUMENT_ACTION_TYPE_ANALYZE and not task_analyze_ids: raise ValueError('Resume failed items currently supports Analyze workflows.') document_ids = [] + document_ids_by_task = {} group_ids = [] public_workspace_ids = [] for item in failed_items: document_id = _normalize_identifier(item.get('document_id')) if document_id and document_id not in document_ids: document_ids.append(document_id) + task_id = _normalize_identifier(item.get('task_id')) + if document_id and task_id: + task_documents = document_ids_by_task.setdefault(task_id, []) + if document_id not in task_documents: + task_documents.append(document_id) scope_type = _normalize_identifier(item.get('scope_type')).lower() scope_id = _normalize_identifier(item.get('scope_id')) if scope_type == FILE_SYNC_SCOPE_GROUP and scope_id and scope_id not in group_ids: @@ -347,15 +391,33 @@ def _build_resume_failed_workflow(workflow, failed_items): raise ValueError('No failed document items are available to resume.') resume_workflow = dict(workflow) - resume_action = dict(action_config) - resume_action.update({ - 'document_ids': document_ids, - 'doc_scope': 'all', - 'active_group_ids': group_ids or list(action_config.get('active_group_ids') or []), - 'active_public_workspace_id': public_workspace_ids or list(action_config.get('active_public_workspace_id') or []), - }) + resume_action = _narrow_analyze_action_to_documents( + action_config, + document_ids, + group_ids, + public_workspace_ids, + ) or dict(action_config) resume_workflow['document_action'] = resume_action resume_workflow['analyze'] = build_analyze_config(resume_action) + + if tasks: + # Task document actions take precedence at run time, so narrow them too or the + # resume would re-run every document the task originally targeted. + resume_tasks = [] + for task in tasks: + resume_task = dict(task) if isinstance(task, dict) else {} + task_id = _normalize_identifier(resume_task.get('id')) + narrowed_task_action = _narrow_analyze_action_to_documents( + resume_task.get('document_action'), + document_ids_by_task.get(task_id, [] if document_ids_by_task else document_ids), + group_ids, + public_workspace_ids, + ) + if narrowed_task_action is not None: + resume_task['document_action'] = narrowed_task_action + resume_tasks.append(resume_task) + resume_workflow['tasks'] = resume_tasks + resume_workflow['file_sync'] = { 'enabled': False, 'wait_mode': 'complete', @@ -1374,11 +1436,18 @@ def resume_failed_group_workflow_items(workflow_id, run_id): resume_workflow = _build_resume_failed_workflow(workflow, failed_items) resume_action = resume_workflow.get('document_action') if isinstance(resume_workflow.get('document_action'), dict) else {} if resume_action: - resume_action['doc_scope'] = 'group' - resume_action['active_group_ids'] = [group_id] - resume_action['active_public_workspace_id'] = [] - resume_workflow['document_action'] = resume_action - resume_workflow['analyze'] = build_analyze_config(resume_action) + resume_workflow['document_action'] = _force_group_document_action_scope(resume_action, group_id) + resume_workflow['analyze'] = build_analyze_config(resume_workflow['document_action']) + if isinstance(resume_workflow.get('tasks'), list): + resume_workflow['tasks'] = [ + { + **task, + 'document_action': _force_group_document_action_scope(task.get('document_action'), group_id), + } + if isinstance(task, dict) and isinstance(task.get('document_action'), dict) + else task + for task in resume_workflow['tasks'] + ] except ValueError as exc: return jsonify({'error': str(exc)}), 400 diff --git a/application/single_app/static/js/chat/chat-documents.js b/application/single_app/static/js/chat/chat-documents.js index 8386f3d7a..3d27e7e24 100644 --- a/application/single_app/static/js/chat/chat-documents.js +++ b/application/single_app/static/js/chat/chat-documents.js @@ -2122,7 +2122,12 @@ function initializeDocumentDropdown() { Load Tags for Selected Scope --------------------------------------------------------------------------- */ export async function loadTagsForScope() { - if (!chatTagsFilter) return; + if (!chatTagsFilter) { + // Keep the tags control out of its initial loading state when the hidden + // filter select is absent, otherwise the button stays stuck on "Loading tags...". + hideTagsDropdown(); + return; + } // Clear existing options in both hidden select and custom dropdown chatTagsFilter.innerHTML = ''; diff --git a/application/single_app/static/js/workspace/workspace_workflows.js b/application/single_app/static/js/workspace/workspace_workflows.js index 437285fa5..f5163010e 100644 --- a/application/single_app/static/js/workspace/workspace_workflows.js +++ b/application/single_app/static/js/workspace/workspace_workflows.js @@ -391,6 +391,7 @@ let workflowPendingDelete = null; let currentHistoryWorkflowId = ""; let currentEditingWorkflow = null; let workflowComparisonVersionLoadToken = 0; +let workflowDocumentPickerLoadToken = 0; let workflowPickerDocumentIds = []; let workflowSavedComparisonTargetIds = []; let workflowSavedComparisonPreferredLeftId = ""; @@ -492,6 +493,145 @@ function getAgentScopeLabel(agent) { return "Personal Agent"; } +function normalizeWorkflowDocumentActionType(value) { + const normalizedValue = normalizeText(value).toLowerCase(); + return [DOCUMENT_ACTION_SEARCH, DOCUMENT_ACTION_ANALYZE, DOCUMENT_ACTION_COMPARISON].includes(normalizedValue) + ? normalizedValue + : DOCUMENT_ACTION_NONE; +} + +function createDefaultWorkflowTaskDocumentAction() { + return { + type: DOCUMENT_ACTION_NONE, + document_ids: [], + left_document_id: "", + right_document_ids: [], + analysis_mode: DOCUMENT_ANALYSIS_MODE_COMBINED, + doc_scope: workflowWorkspaceConfig.scope === "group" ? "group" : "all", + active_group_ids: [], + active_public_workspace_id: [], + window_unit: "pages", + window_size: "", + window_percent: "", + max_retries_per_window: 1, + target_mode: DOCUMENT_ANALYSIS_TARGET_SELECTED, + recent_window_minutes: DEFAULT_RECENT_DOCUMENT_WINDOW_MINUTES, + }; +} + +function normalizeWorkflowTaskDocumentAction(rawAction) { + const source = rawAction && typeof rawAction === "object" ? rawAction : {}; + if (normalizeWorkflowDocumentActionType(source.type) === DOCUMENT_ACTION_NONE) { + return createDefaultWorkflowTaskDocumentAction(); + } + + const normalizedAction = getDocumentActionConfig({ document_action: source }); + return { + ...normalizedAction, + document_ids: normalizeIdList(normalizedAction.document_ids), + right_document_ids: normalizeIdList(normalizedAction.right_document_ids), + active_group_ids: normalizeIdList(normalizedAction.active_group_ids), + active_public_workspace_id: normalizeIdList(normalizedAction.active_public_workspace_id), + }; +} + +function readWorkflowDocumentActionFromForm() { + const actionType = normalizeWorkflowDocumentActionType(workflowDocumentActionTypeSelect?.value); + if (actionType === DOCUMENT_ACTION_NONE) { + return createDefaultWorkflowTaskDocumentAction(); + } + + const targetMode = normalizeText(workflowAnalysisTargetModeSelect?.value) === DOCUMENT_ANALYSIS_TARGET_RECENT + ? DOCUMENT_ANALYSIS_TARGET_RECENT + : DOCUMENT_ANALYSIS_TARGET_SELECTED; + const isRecentMode = targetMode === DOCUMENT_ANALYSIS_TARGET_RECENT; + const pickerDocumentIds = isRecentMode ? [] : getWorkflowPickerSelectedDocumentIds(); + const comparisonLeftDocumentId = isRecentMode ? "" : normalizeText(workflowComparisonLeftDocumentIdInput?.value); + const comparisonTargetDocumentIds = isRecentMode ? [] : getSelectedWorkflowComparisonTargetIds(); + const activeGroupId = getWorkflowActiveGroupId(); + + return { + type: actionType, + document_ids: actionType === DOCUMENT_ACTION_COMPARISON ? comparisonTargetDocumentIds : pickerDocumentIds, + left_document_id: actionType === DOCUMENT_ACTION_COMPARISON ? comparisonLeftDocumentId : "", + right_document_ids: actionType === DOCUMENT_ACTION_COMPARISON + ? comparisonTargetDocumentIds.filter((documentId) => documentId !== comparisonLeftDocumentId) + : [], + analysis_mode: actionType === DOCUMENT_ACTION_ANALYZE && workflowAnalysisPerDocumentToggle?.checked + ? DOCUMENT_ANALYSIS_MODE_PER_DOCUMENT + : DOCUMENT_ANALYSIS_MODE_COMBINED, + doc_scope: normalizeText(workflowAnalysisDocScopeSelect?.value) || getWorkflowDocumentScope(), + active_group_ids: workflowWorkspaceConfig.scope === "group" && activeGroupId + ? [activeGroupId] + : parseCsvList(workflowAnalysisGroupIdsInput?.value), + active_public_workspace_id: parseCsvList(workflowAnalysisPublicWorkspaceIdsInput?.value), + window_unit: normalizeText(workflowAnalysisWindowUnitSelect?.value) || "pages", + window_size: normalizeText(workflowAnalysisWindowSizeInput?.value), + window_percent: normalizeText(workflowAnalysisWindowPercentInput?.value), + max_retries_per_window: normalizeWorkflowNumericField(workflowAnalysisRetriesInput?.value, "1"), + target_mode: targetMode, + recent_window_minutes: normalizeWorkflowNumericField( + workflowAnalysisRecentMinutesInput?.value, + String(DEFAULT_RECENT_DOCUMENT_WINDOW_MINUTES), + ), + }; +} + +function applyWorkflowDocumentActionToForm(rawAction) { + const action = normalizeWorkflowTaskDocumentAction(rawAction); + + if (workflowDocumentActionTypeSelect) { + workflowDocumentActionTypeSelect.value = action.type; + } + if (workflowAnalysisDocScopeSelect) { + workflowAnalysisDocScopeSelect.value = action.doc_scope; + } + if (workflowAnalysisTargetModeSelect) { + workflowAnalysisTargetModeSelect.value = action.target_mode === DOCUMENT_ANALYSIS_TARGET_RECENT + ? DOCUMENT_ANALYSIS_TARGET_RECENT + : DOCUMENT_ANALYSIS_TARGET_SELECTED; + } + if (workflowAnalysisRecentMinutesInput) { + workflowAnalysisRecentMinutesInput.value = String(action.recent_window_minutes || DEFAULT_RECENT_DOCUMENT_WINDOW_MINUTES); + } + if (workflowAnalysisPerDocumentToggle) { + workflowAnalysisPerDocumentToggle.checked = action.analysis_mode === DOCUMENT_ANALYSIS_MODE_PER_DOCUMENT; + } + if (workflowAnalysisGroupIdsInput) { + workflowAnalysisGroupIdsInput.value = joinCsvList(action.active_group_ids); + } + if (workflowAnalysisPublicWorkspaceIdsInput) { + workflowAnalysisPublicWorkspaceIdsInput.value = joinCsvList(action.active_public_workspace_id); + } + if (workflowAnalysisWindowUnitSelect) { + workflowAnalysisWindowUnitSelect.value = action.window_unit; + } + if (workflowAnalysisWindowSizeInput) { + workflowAnalysisWindowSizeInput.value = action.window_size ?? ""; + } + if (workflowAnalysisWindowPercentInput) { + workflowAnalysisWindowPercentInput.value = action.window_percent ?? ""; + } + if (workflowAnalysisRetriesInput) { + workflowAnalysisRetriesInput.value = String(action.max_retries_per_window ?? 1); + } + + applyWorkflowPickerSelection(action.type === DOCUMENT_ACTION_COMPARISON ? [] : action.document_ids); + + if (action.type === DOCUMENT_ACTION_COMPARISON) { + const savedTargetIds = normalizeIdList([action.left_document_id, ...action.right_document_ids]); + workflowSavedComparisonTargetIds = savedTargetIds; + workflowSavedComparisonPreferredLeftId = action.left_document_id; + setWorkflowComparisonSavedTargets(savedTargetIds, action.left_document_id); + } else { + workflowSavedComparisonTargetIds = []; + workflowSavedComparisonPreferredLeftId = ""; + setWorkflowComparisonSavedTargets([], ""); + } + + updateDocumentActionFields(); +} + function getWorkflowDefaultRunnerSummary() { if (normalizeText(workflowRunnerTypeSelect?.value) === "agent") { const agent = getSelectedAgentOption(); @@ -727,6 +867,7 @@ function syncActiveWorkflowTaskFromEditor() { } else { activeTask.runner = { type: "inherit" }; } + activeTask.document_action = readWorkflowDocumentActionFromForm(); } function populateWorkflowTaskEditor() { @@ -742,6 +883,10 @@ function populateWorkflowTaskEditor() { workflowTaskRunnerTypeSelect.value = runner.type; } updateWorkflowTaskRunnerFields(runner); + applyWorkflowDocumentActionToForm(activeTask?.document_action); + ensureWorkflowDocumentPickerLoaded().catch((error) => { + setWorkflowPickerError(error.message || "Unable to load documents for this workflow."); + }); if (workflowTaskBriefInput) { workflowTaskBriefInput.value = ""; } @@ -792,7 +937,10 @@ function renderWorkflowTasks() { const runner = document.createElement("div"); runner.className = "workflow-task-item__runner small text-muted mt-1"; runner.textContent = getWorkflowTaskRunnerSummary(task); - content.append(name, preview, runner); + const documents = document.createElement("div"); + documents.className = "workflow-task-item__documents small text-muted"; + documents.textContent = `Documents: ${getWorkflowDocumentActionSummary({ document_action: task.document_action })}`; + content.append(name, preview, runner, documents); const actions = document.createElement("div"); actions.className = "workflow-task-item__actions"; @@ -831,6 +979,7 @@ function addWorkflowTask() { instructions: "", order: workflowTasks.length + 1, runner: { type: "inherit" }, + document_action: createDefaultWorkflowTaskDocumentAction(), }; workflowTasks.push(task); activeWorkflowTaskId = task.id; @@ -856,6 +1005,7 @@ function removeWorkflowTask(taskId) { showToast("A workflow requires at least one task.", "warning"); return; } + syncActiveWorkflowTaskFromEditor(); const taskIndex = workflowTasks.findIndex((task) => task.id === taskId); if (taskIndex < 0) { return; @@ -869,6 +1019,15 @@ function removeWorkflowTask(taskId) { function initializeWorkflowTasks(workflow = null) { const storedTasks = Array.isArray(workflow?.tasks) ? workflow.tasks : []; + const workflowDocumentAction = workflow ? getDocumentActionConfig(workflow) : null; + const resolveTaskDocumentAction = (task, index) => { + // Legacy workflows stored a single workflow-level action that only ever ran on task 1. + if (index === 0 && !(task?.document_action && typeof task.document_action === "object") && workflowDocumentAction) { + return normalizeWorkflowTaskDocumentAction(workflowDocumentAction); + } + return normalizeWorkflowTaskDocumentAction(task?.document_action); + }; + workflowTasks = storedTasks.length ? storedTasks.map((task, index) => ({ id: normalizeText(task.id) || createWorkflowTaskId(), @@ -877,6 +1036,7 @@ function initializeWorkflowTasks(workflow = null) { instructions: normalizeText(task.instructions), order: index + 1, runner: normalizeWorkflowTaskRunner(task.runner), + document_action: resolveTaskDocumentAction(task, index), })) : [{ id: createWorkflowTaskId(), @@ -885,6 +1045,7 @@ function initializeWorkflowTasks(workflow = null) { instructions: normalizeText(workflow?.task_prompt), order: 1, runner: { type: "inherit" }, + document_action: resolveTaskDocumentAction(null, 0), }]; activeWorkflowTaskId = workflowTasks[0].id; populateWorkflowTaskEditor(); @@ -941,23 +1102,30 @@ function renderWorkflowReview() { clearElementChildren(workflowReviewSummary); const runnerLabel = workflowRunnerTypeSelect?.selectedOptions?.[0]?.textContent || "Direct Model"; const triggerLabel = workflowTriggerTypeSelect?.selectedOptions?.[0]?.textContent || "Manual"; - const documentActionLabel = workflowDocumentActionTypeSelect?.selectedOptions?.[0]?.textContent || "No document action"; const alertLabel = workflowAlertPrioritySelect?.selectedOptions?.[0]?.textContent || "No notification"; const retryCount = Number(workflowTaskRetryCountInput?.value || 0); const strategyLabel = getWorkflowErrorStrategy() === "continue" ? "Continue after failure" : "Stop after failure"; + const documentTaskCount = workflowTasks.filter( + (task) => normalizeWorkflowDocumentActionType(task.document_action?.type) !== DOCUMENT_ACTION_NONE + ).length; addWorkflowReviewItem("Workflow", normalizeText(workflowNameInput?.value) || "Untitled workflow"); addWorkflowReviewItem("Default Runner", normalizeText(runnerLabel)); addWorkflowReviewItem("Trigger", normalizeText(triggerLabel)); addWorkflowReviewItem("Tasks", `${workflowTasks.length} ordered ${workflowTasks.length === 1 ? "task" : "tasks"}`); - addWorkflowReviewItem("Workspace documents", normalizeText(documentActionLabel)); + addWorkflowReviewItem( + "Workspace documents", + documentTaskCount + ? `${documentTaskCount} of ${workflowTasks.length} ${workflowTasks.length === 1 ? "task uses" : "tasks use"} workspace documents` + : "No document action", + ); addWorkflowReviewItem("File Sync", workflowFileSyncEnabledToggle?.checked ? "Before each run" : "Not used"); addWorkflowReviewItem("Failure handling", `${strategyLabel}; ${retryCount} ${retryCount === 1 ? "retry" : "retries"}`); addWorkflowReviewItem("Alerts", getWorkflowAlertReviewSummary(alertLabel)); workflowTasks.forEach((task, index) => { addWorkflowReviewItem( `Task ${index + 1}`, - `${normalizeText(task.name) || `Task ${index + 1}`} - ${getWorkflowTaskRunnerSummary(task)}`, + `${normalizeText(task.name) || `Task ${index + 1}`} - ${getWorkflowTaskRunnerSummary(task)} - ${getWorkflowDocumentActionSummary({ document_action: task.document_action })}`, ); }); } @@ -999,6 +1167,20 @@ function validateWorkflowTasks() { workflowTaskAgentSelect?.focus(); return false; } + try { + validateWorkflowTaskDocumentAction( + serializeWorkflowDocumentAction(task.document_action), + getWorkflowTaskLabel(task, index), + Boolean(workflowFileSyncEnabledToggle?.checked) && workflowFileSyncUseChangedDocumentsToggle?.checked !== false, + ); + } catch (error) { + activeWorkflowTaskId = task.id; + populateWorkflowTaskEditor(); + renderWorkflowTasks(); + showToast(escapeHtml(error.message || `Complete the workspace document setup for task ${index + 1}.`), "warning"); + workflowDocumentActionTypeSelect?.focus(); + return false; + } } return true; } @@ -1998,12 +2180,17 @@ function setWorkflowPickerError(message = "") { workflowDocumentPickerError.classList.toggle("d-none", !message); } -async function initializeWorkflowDocumentPicker(documentAction = {}) { +async function initializeWorkflowDocumentPicker(documentAction = {}, options = {}) { if (!workflowDocumentPickerCard) { return; } const actionType = normalizeText(documentAction.type || workflowDocumentActionTypeSelect?.value) || DOCUMENT_ACTION_NONE; + // Bump first so any in-flight load for a previous task cannot apply its scopes or + // selection to the picker after this call resolves. + const requestToken = workflowDocumentPickerLoadToken + 1; + workflowDocumentPickerLoadToken = requestToken; + if (actionType === DOCUMENT_ACTION_NONE) { setWorkflowPickerError(""); setWorkflowPickerLoadingState(false); @@ -2015,6 +2202,11 @@ async function initializeWorkflowDocumentPicker(documentAction = {}) { syncWorkflowPickerActionType(); const pickerScopes = getWorkflowPickerScopesFromAction(documentAction); + const pickerSelectionIds = Array.isArray(options.pickerDocumentIds) + ? options.pickerDocumentIds + : actionType === DOCUMENT_ACTION_COMPARISON + ? [] + : documentAction.document_ids || []; try { await setEffectiveScopes(pickerScopes, { force: workflowWorkspaceConfig.scope === "group", @@ -2022,19 +2214,41 @@ async function initializeWorkflowDocumentPicker(documentAction = {}) { reload: true, }); await ensureDocumentPickerReady({ reload: false, showLoading: false }); + if (requestToken !== workflowDocumentPickerLoadToken) { + return; + } syncWorkflowScopeFieldsFromPicker(pickerScopes); - applyWorkflowPickerSelection( - documentAction.type === DOCUMENT_ACTION_COMPARISON - ? [] - : documentAction.document_ids || [] - ); + applyWorkflowPickerSelection(pickerSelectionIds); } catch (error) { - setWorkflowPickerError(error.message || "Unable to load documents for this workflow."); + if (requestToken === workflowDocumentPickerLoadToken) { + setWorkflowPickerError(error.message || "Unable to load documents for this workflow."); + } } finally { - setWorkflowPickerLoadingState(false); + if (requestToken === workflowDocumentPickerLoadToken) { + setWorkflowPickerLoadingState(false); + } } } +function getWorkflowPickerAvailableDocumentCount() { + return document.querySelectorAll("#document-dropdown-items .dropdown-item[data-document-id]").length; +} + +function ensureWorkflowDocumentPickerLoaded(options = {}) { + const formAction = readWorkflowDocumentActionFromForm(); + return initializeWorkflowDocumentPicker( + formAction, + options.preserveSelection ? { pickerDocumentIds: getWorkflowPickerSelectedDocumentIds() } : {}, + ); +} + +function handleWorkflowDocumentActionSelectionChanged() { + updateDocumentActionFields(); + ensureWorkflowDocumentPickerLoaded().catch((error) => { + setWorkflowPickerError(error.message || "Unable to load documents for this workflow."); + }); +} + async function refreshWorkflowComparisonTargetsFromPicker() { if (normalizeText(workflowDocumentActionTypeSelect?.value) !== DOCUMENT_ACTION_COMPARISON) { return; @@ -2839,17 +3053,25 @@ function updateDocumentActionFields() { } async function applySelectedWorkspaceDocumentsToWorkflow() { - const selectedIds = getWorkflowPickerSelectedDocumentIds(); - if (!selectedIds.length) { - const selectedLabel = normalizeText(workflowWorkspaceConfig.selectedDocumentsLabel) || "workspace"; - showToast(`Select one or more ${selectedLabel} documents in the picker first.`, "warning"); + const actionType = normalizeText(workflowDocumentActionTypeSelect?.value) || DOCUMENT_ACTION_NONE; + if (actionType === DOCUMENT_ACTION_NONE) { return; } - const actionType = normalizeText(workflowDocumentActionTypeSelect?.value) || DOCUMENT_ACTION_NONE; - if (actionType === DOCUMENT_ACTION_NONE) { + await ensureWorkflowDocumentPickerLoaded({ preserveSelection: true }); + + const selectedIds = getWorkflowPickerSelectedDocumentIds(); + const selectedLabel = normalizeText(workflowWorkspaceConfig.selectedDocumentsLabel) || "workspace"; + if (!selectedIds.length) { + showToast( + getWorkflowPickerAvailableDocumentCount() + ? `Document list refreshed. Select one or more ${selectedLabel} documents in the picker for this task.` + : `Document list refreshed. No ${selectedLabel} documents are available in the selected scope.`, + "info", + ); return; } + const workflowMaxDocuments = getWorkflowDocumentActionMaxDocuments(actionType); const limitedSelectedIds = selectedIds.slice(0, workflowMaxDocuments); @@ -3744,45 +3966,6 @@ async function openWorkflowModal(workflow = null) { workflowFileSyncUseChangedDocumentsToggle.checked = fileSyncConfig.use_changed_documents !== false; } populateFileSyncSourceSelect(Array.isArray(fileSyncConfig.sources) ? fileSyncConfig.sources : []); - const documentAction = getDocumentActionConfig(workflow); - if (workflowDocumentActionTypeSelect) { - workflowDocumentActionTypeSelect.value = documentAction.type; - } - if (workflowAnalysisDocScopeSelect) { - workflowAnalysisDocScopeSelect.value = documentAction.doc_scope; - } - if (workflowAnalysisTargetModeSelect) { - workflowAnalysisTargetModeSelect.value = documentAction.target_mode === DOCUMENT_ANALYSIS_TARGET_RECENT - ? DOCUMENT_ANALYSIS_TARGET_RECENT - : DOCUMENT_ANALYSIS_TARGET_SELECTED; - } - if (workflowAnalysisRecentMinutesInput) { - workflowAnalysisRecentMinutesInput.value = String(documentAction.recent_window_minutes || DEFAULT_RECENT_DOCUMENT_WINDOW_MINUTES); - } - if (workflowAnalysisDocumentIdsInput) { - workflowAnalysisDocumentIdsInput.value = joinCsvList(documentAction.document_ids); - } - if (workflowAnalysisPerDocumentToggle) { - workflowAnalysisPerDocumentToggle.checked = documentAction.analysis_mode === DOCUMENT_ANALYSIS_MODE_PER_DOCUMENT; - } - if (workflowAnalysisGroupIdsInput) { - workflowAnalysisGroupIdsInput.value = joinCsvList(documentAction.active_group_ids); - } - if (workflowAnalysisPublicWorkspaceIdsInput) { - workflowAnalysisPublicWorkspaceIdsInput.value = joinCsvList(documentAction.active_public_workspace_id); - } - if (workflowAnalysisWindowUnitSelect) { - workflowAnalysisWindowUnitSelect.value = documentAction.window_unit; - } - if (workflowAnalysisWindowSizeInput) { - workflowAnalysisWindowSizeInput.value = documentAction.window_size; - } - if (workflowAnalysisWindowPercentInput) { - workflowAnalysisWindowPercentInput.value = documentAction.window_percent; - } - if (workflowAnalysisRetriesInput) { - workflowAnalysisRetriesInput.value = String(documentAction.max_retries_per_window); - } if (workflowScheduleValueInput) { workflowScheduleValueInput.value = String(workflow.schedule?.value || 10); } @@ -3807,17 +3990,6 @@ async function openWorkflowModal(workflow = null) { } initializeWorkflowTasks(workflow); - const documentAction = workflow ? getDocumentActionConfig(workflow) : null; - if (documentAction?.type === DOCUMENT_ACTION_COMPARISON) { - const savedTargetIds = [documentAction.left_document_id, ...documentAction.right_document_ids].filter(Boolean); - workflowSavedComparisonTargetIds = savedTargetIds; - workflowSavedComparisonPreferredLeftId = documentAction.left_document_id; - setWorkflowComparisonSavedTargets(savedTargetIds, documentAction.left_document_id); - } else { - workflowSavedComparisonTargetIds = []; - workflowSavedComparisonPreferredLeftId = ""; - setWorkflowComparisonSavedTargets([], ""); - } updateRunnerFields(); updateTriggerFields(); @@ -3825,7 +3997,152 @@ async function openWorkflowModal(workflow = null) { updateDocumentActionFields(); showWorkflowStep(0); workflowModal.show(); - await initializeWorkflowDocumentPicker(documentAction || {}); + await ensureWorkflowDocumentPickerLoaded(); +} + +function normalizeWorkflowNumericField(value, fallback) { + // normalizeText() coerces a numeric 0 to "", so truthiness cannot be used here: + // 0 is a valid retries-per-window value and must survive serialization. + if (value === null || value === undefined) { + return fallback; + } + const normalizedValue = normalizeText(value); + return normalizedValue === "" ? fallback : normalizedValue; +} + +function serializeWorkflowDocumentAction(rawAction) { + const source = rawAction && typeof rawAction === "object" ? rawAction : createDefaultWorkflowTaskDocumentAction(); + const actionType = normalizeWorkflowDocumentActionType(source.type); + const hasAction = actionType !== DOCUMENT_ACTION_NONE; + const targetMode = hasAction && normalizeText(source.target_mode) === DOCUMENT_ANALYSIS_TARGET_RECENT + ? DOCUMENT_ANALYSIS_TARGET_RECENT + : DOCUMENT_ANALYSIS_TARGET_SELECTED; + const isRecentMode = targetMode === DOCUMENT_ANALYSIS_TARGET_RECENT; + const rawWindowSize = normalizeText(source.window_size); + const rawWindowPercent = normalizeText(source.window_percent); + const rawRetries = normalizeWorkflowNumericField(source.max_retries_per_window, "1"); + const rawRecentMinutes = normalizeWorkflowNumericField( + source.recent_window_minutes, + String(DEFAULT_RECENT_DOCUMENT_WINDOW_MINUTES), + ); + const activeGroupId = getWorkflowActiveGroupId(); + const comparisonLeftDocumentId = actionType === DOCUMENT_ACTION_COMPARISON && !isRecentMode + ? normalizeText(source.left_document_id) + : ""; + + return { + type: actionType, + document_ids: hasAction && !isRecentMode ? normalizeIdList(source.document_ids) : [], + left_document_id: comparisonLeftDocumentId, + right_document_ids: actionType === DOCUMENT_ACTION_COMPARISON && !isRecentMode + ? normalizeIdList(source.right_document_ids).filter((documentId) => documentId !== comparisonLeftDocumentId) + : [], + analysis_mode: actionType === DOCUMENT_ACTION_ANALYZE + ? normalizeWorkflowAnalysisMode(source.analysis_mode) + : DOCUMENT_ANALYSIS_MODE_COMBINED, + doc_scope: normalizeText(source.doc_scope) || getWorkflowDocumentScope(), + active_group_ids: hasAction + ? workflowWorkspaceConfig.scope === "group" && activeGroupId + ? [activeGroupId] + : normalizeIdList(source.active_group_ids) + : [], + active_public_workspace_id: hasAction ? normalizeIdList(source.active_public_workspace_id) : [], + window_unit: normalizeText(source.window_unit) || "pages", + window_size: rawWindowSize ? Number(rawWindowSize) : null, + window_percent: rawWindowPercent ? Number(rawWindowPercent) : null, + max_retries_per_window: Number(rawRetries), + target_mode: targetMode, + recent_window_minutes: Number(rawRecentMinutes), + }; +} + +function buildWorkflowAnalyzeConfig(actionPayload) { + const action = actionPayload && typeof actionPayload === "object" ? actionPayload : serializeWorkflowDocumentAction(); + const isAnalyze = action.type === DOCUMENT_ACTION_ANALYZE; + + return { + enabled: isAnalyze, + document_ids: isAnalyze ? action.document_ids : [], + doc_scope: action.doc_scope, + active_group_ids: isAnalyze ? action.active_group_ids : [], + active_public_workspace_id: isAnalyze ? action.active_public_workspace_id : [], + analysis_mode: isAnalyze ? action.analysis_mode : DOCUMENT_ANALYSIS_MODE_COMBINED, + window_unit: action.window_unit, + window_size: action.window_size, + window_percent: action.window_percent, + max_retries_per_window: action.max_retries_per_window, + target_mode: isAnalyze ? action.target_mode : DOCUMENT_ANALYSIS_TARGET_SELECTED, + recent_window_minutes: action.recent_window_minutes, + }; +} + +function validateWorkflowTaskDocumentAction(actionPayload, taskLabel, usesDynamicFileSyncTargets = false) { + const actionType = actionPayload.type; + if (actionType === DOCUMENT_ACTION_NONE) { + return; + } + + const isSelectedMode = actionPayload.target_mode === DOCUMENT_ANALYSIS_TARGET_SELECTED; + const prefix = `${taskLabel}: `; + + if (!isDocumentActionEnabled(actionType)) { + throw new Error(`${prefix}${getDocumentActionDisplayLabel(actionType)} is currently disabled by an administrator.`); + } + if (actionType === DOCUMENT_ACTION_SEARCH && isSelectedMode && !actionPayload.document_ids.length) { + throw new Error(`${prefix}Select one or more documents for search.`); + } + if (actionType === DOCUMENT_ACTION_ANALYZE && isSelectedMode && !actionPayload.document_ids.length && !usesDynamicFileSyncTargets) { + throw new Error(`${prefix}Select one or more documents for analysis.`); + } + if (!isSelectedMode && ( + !Number.isInteger(actionPayload.recent_window_minutes) + || actionPayload.recent_window_minutes < 1 + || actionPayload.recent_window_minutes > 1440 + )) { + throw new Error(`${prefix}Recent document window must be between 1 and 1440 minutes.`); + } + if (actionType === DOCUMENT_ACTION_COMPARISON && isSelectedMode) { + if (actionPayload.document_ids.length < 2) { + throw new Error(`${prefix}Select at least two document versions for compare.`); + } + if (!actionPayload.left_document_id) { + throw new Error(`${prefix}Add one Source document id for compare.`); + } + if (!actionPayload.right_document_ids.length) { + throw new Error(`${prefix}Add one or more Target document ids for compare.`); + } + } + + const documentActionCount = actionType === DOCUMENT_ACTION_COMPARISON + ? 1 + actionPayload.right_document_ids.length + : actionPayload.document_ids.length; + const workflowMaxDocuments = getWorkflowDocumentActionMaxDocuments(actionType); + if (documentActionCount > workflowMaxDocuments) { + throw new Error(`${prefix}${getDocumentActionDisplayLabel(actionType)} workflows support up to ${workflowMaxDocuments} documents per run.`); + } + if (actionPayload.window_size !== null && (!Number.isInteger(actionPayload.window_size) || actionPayload.window_size < 1)) { + throw new Error(`${prefix}Window size must be a whole number greater than zero.`); + } + if (actionPayload.window_percent !== null && ( + !Number.isInteger(actionPayload.window_percent) + || actionPayload.window_percent < 1 + || actionPayload.window_percent > 100 + )) { + throw new Error(`${prefix}Window percent must be a whole number between 1 and 100.`); + } + if (actionPayload.window_size !== null && actionPayload.window_percent !== null) { + throw new Error(`${prefix}Choose either a fixed window size or a window percent, not both.`); + } + if (!Number.isInteger(actionPayload.max_retries_per_window) + || actionPayload.max_retries_per_window < 0 + || actionPayload.max_retries_per_window > 5) { + throw new Error(`${prefix}Retries per window must be between 0 and 5.`); + } +} + +function getWorkflowTaskLabel(task, index) { + const taskName = normalizeText(task?.name); + return taskName ? `Task ${index + 1} (${taskName})` : `Task ${index + 1}`; } function buildWorkflowPayload() { @@ -3837,35 +4154,13 @@ function buildWorkflowPayload() { instructions: normalizeText(task.instructions), order: index + 1, runner: serializeWorkflowTaskRunner(task.runner), + document_action: serializeWorkflowDocumentAction(task.document_action), })); const runnerType = normalizeText(workflowRunnerTypeSelect?.value) || "model"; const triggerType = normalizeText(workflowTriggerTypeSelect?.value) || "manual"; - const documentActionType = normalizeText(workflowDocumentActionTypeSelect?.value) || DOCUMENT_ACTION_NONE; - const analysisTargetMode = normalizeText(workflowAnalysisTargetModeSelect?.value) === DOCUMENT_ANALYSIS_TARGET_RECENT - ? DOCUMENT_ANALYSIS_TARGET_RECENT - : DOCUMENT_ANALYSIS_TARGET_SELECTED; - const targetDocumentIds = analysisTargetMode === DOCUMENT_ANALYSIS_TARGET_RECENT - ? [] - : getWorkflowPickerSelectedDocumentIds(); - const comparisonLeftDocumentId = normalizeText(workflowComparisonLeftDocumentIdInput?.value); - const comparisonTargetDocumentIds = analysisTargetMode === DOCUMENT_ANALYSIS_TARGET_RECENT - ? [] - : getSelectedWorkflowComparisonTargetIds(); - const selectedDocumentActionIds = documentActionType === DOCUMENT_ACTION_COMPARISON - ? comparisonTargetDocumentIds - : targetDocumentIds; - const comparisonRightDocumentIds = analysisTargetMode === DOCUMENT_ANALYSIS_TARGET_RECENT - ? [] - : comparisonTargetDocumentIds.filter((documentId) => documentId !== comparisonLeftDocumentId); - const analysisGroupIds = parseCsvList(workflowAnalysisGroupIdsInput?.value); - const analysisPublicWorkspaceIds = parseCsvList(workflowAnalysisPublicWorkspaceIdsInput?.value); - const rawWindowSize = normalizeText(workflowAnalysisWindowSizeInput?.value); - const rawWindowPercent = normalizeText(workflowAnalysisWindowPercentInput?.value); - const rawRetries = normalizeText(workflowAnalysisRetriesInput?.value) || "1"; - const rawRecentMinutes = normalizeText(workflowAnalysisRecentMinutesInput?.value) || String(DEFAULT_RECENT_DOCUMENT_WINDOW_MINUTES); - const analysisMode = workflowAnalysisPerDocumentToggle?.checked - ? DOCUMENT_ANALYSIS_MODE_PER_DOCUMENT - : DOCUMENT_ANALYSIS_MODE_COMBINED; + const primaryDocumentAction = tasks.find((task) => task.document_action.type !== DOCUMENT_ACTION_NONE)?.document_action + || tasks[0]?.document_action + || serializeWorkflowDocumentAction(); const fileSyncEnabled = Boolean(workflowFileSyncEnabledToggle?.checked) || triggerType === "file_sync"; const payload = { id: normalizeText(workflowIdInput?.value), @@ -3901,44 +4196,8 @@ function buildWorkflowPayload() { selected_agent: {}, model_endpoint_id: "", model_id: "", - document_action: { - type: documentActionType, - document_ids: documentActionType !== DOCUMENT_ACTION_NONE ? selectedDocumentActionIds : [], - left_document_id: documentActionType === DOCUMENT_ACTION_COMPARISON && analysisTargetMode !== DOCUMENT_ANALYSIS_TARGET_RECENT ? comparisonLeftDocumentId : "", - right_document_ids: documentActionType === DOCUMENT_ACTION_COMPARISON ? comparisonRightDocumentIds : [], - analysis_mode: documentActionType === DOCUMENT_ACTION_ANALYZE ? analysisMode : DOCUMENT_ANALYSIS_MODE_COMBINED, - doc_scope: normalizeText(workflowAnalysisDocScopeSelect?.value) || getWorkflowDocumentScope(), - active_group_ids: documentActionType !== DOCUMENT_ACTION_NONE - ? workflowWorkspaceConfig.scope === "group" && getWorkflowActiveGroupId() - ? [getWorkflowActiveGroupId()] - : analysisGroupIds - : [], - active_public_workspace_id: documentActionType !== DOCUMENT_ACTION_NONE ? analysisPublicWorkspaceIds : [], - window_unit: normalizeText(workflowAnalysisWindowUnitSelect?.value) || "pages", - window_size: rawWindowSize ? Number(rawWindowSize) : null, - window_percent: rawWindowPercent ? Number(rawWindowPercent) : null, - max_retries_per_window: Number(rawRetries), - target_mode: documentActionType !== DOCUMENT_ACTION_NONE ? analysisTargetMode : DOCUMENT_ANALYSIS_TARGET_SELECTED, - recent_window_minutes: Number(rawRecentMinutes), - }, - analyze: { - enabled: documentActionType === DOCUMENT_ACTION_ANALYZE, - document_ids: documentActionType === DOCUMENT_ACTION_ANALYZE ? targetDocumentIds : [], - doc_scope: normalizeText(workflowAnalysisDocScopeSelect?.value) || getWorkflowDocumentScope(), - active_group_ids: documentActionType === DOCUMENT_ACTION_ANALYZE - ? workflowWorkspaceConfig.scope === "group" && getWorkflowActiveGroupId() - ? [getWorkflowActiveGroupId()] - : analysisGroupIds - : [], - active_public_workspace_id: documentActionType === DOCUMENT_ACTION_ANALYZE ? analysisPublicWorkspaceIds : [], - analysis_mode: documentActionType === DOCUMENT_ACTION_ANALYZE ? analysisMode : DOCUMENT_ANALYSIS_MODE_COMBINED, - window_unit: normalizeText(workflowAnalysisWindowUnitSelect?.value) || "pages", - window_size: rawWindowSize ? Number(rawWindowSize) : null, - window_percent: rawWindowPercent ? Number(rawWindowPercent) : null, - max_retries_per_window: Number(rawRetries), - target_mode: documentActionType === DOCUMENT_ACTION_ANALYZE ? analysisTargetMode : DOCUMENT_ANALYSIS_TARGET_SELECTED, - recent_window_minutes: Number(rawRecentMinutes), - }, + document_action: primaryDocumentAction, + analyze: buildWorkflowAnalyzeConfig(primaryDocumentAction), }; if (!payload.name) { @@ -3962,46 +4221,13 @@ function buildWorkflowPayload() { } } const usesDynamicFileSyncTargets = payload.file_sync.enabled && payload.file_sync.use_changed_documents; - if (documentActionType === DOCUMENT_ACTION_SEARCH && analysisTargetMode === DOCUMENT_ANALYSIS_TARGET_SELECTED && !payload.document_action.document_ids.length) { - throw new Error("Select one or more documents for search."); - } - if (documentActionType === DOCUMENT_ACTION_ANALYZE && analysisTargetMode === DOCUMENT_ANALYSIS_TARGET_SELECTED && !payload.document_action.document_ids.length && !usesDynamicFileSyncTargets) { - throw new Error("Select one or more documents for analysis."); - } - if (documentActionType !== DOCUMENT_ACTION_NONE && analysisTargetMode === DOCUMENT_ANALYSIS_TARGET_RECENT && (!Number.isInteger(payload.document_action.recent_window_minutes) || payload.document_action.recent_window_minutes < 1 || payload.document_action.recent_window_minutes > 1440)) { - throw new Error("Recent document window must be between 1 and 1440 minutes."); - } - if (documentActionType === DOCUMENT_ACTION_COMPARISON && analysisTargetMode === DOCUMENT_ANALYSIS_TARGET_SELECTED && payload.document_action.document_ids.length < 2) { - throw new Error("Select at least two document versions for compare."); - } - if (documentActionType === DOCUMENT_ACTION_COMPARISON && analysisTargetMode === DOCUMENT_ANALYSIS_TARGET_SELECTED && !payload.document_action.left_document_id) { - throw new Error("Add one Source document id for compare."); - } - if (documentActionType === DOCUMENT_ACTION_COMPARISON && analysisTargetMode === DOCUMENT_ANALYSIS_TARGET_SELECTED && !payload.document_action.right_document_ids.length) { - throw new Error("Add one or more Target document ids for compare."); - } - if (documentActionType !== DOCUMENT_ACTION_NONE && !isDocumentActionEnabled(documentActionType)) { - throw new Error(`${getDocumentActionDisplayLabel(documentActionType)} is currently disabled by an administrator.`); - } - const documentActionCount = documentActionType === DOCUMENT_ACTION_COMPARISON - ? 1 + payload.document_action.right_document_ids.length - : payload.document_action.document_ids.length; - const workflowMaxDocuments = getWorkflowDocumentActionMaxDocuments(documentActionType); - if (documentActionCount > workflowMaxDocuments) { - throw new Error(`${getDocumentActionDisplayLabel(documentActionType)} workflows support up to ${workflowMaxDocuments} documents per run.`); - } - if (documentActionType !== DOCUMENT_ACTION_NONE && rawWindowSize && (!Number.isInteger(payload.document_action.window_size) || payload.document_action.window_size < 1)) { - throw new Error("Window size must be a whole number greater than zero."); - } - if (documentActionType !== DOCUMENT_ACTION_NONE && rawWindowPercent && (!Number.isInteger(payload.document_action.window_percent) || payload.document_action.window_percent < 1 || payload.document_action.window_percent > 100)) { - throw new Error("Window percent must be a whole number between 1 and 100."); - } - if (documentActionType !== DOCUMENT_ACTION_NONE && rawWindowSize && rawWindowPercent) { - throw new Error("Choose either a fixed window size or a window percent, not both."); - } - if (documentActionType !== DOCUMENT_ACTION_NONE && (!Number.isInteger(payload.document_action.max_retries_per_window) || payload.document_action.max_retries_per_window < 0 || payload.document_action.max_retries_per_window > 5)) { - throw new Error("Retries per window must be between 0 and 5."); - } + payload.tasks.forEach((task, index) => { + validateWorkflowTaskDocumentAction( + task.document_action, + getWorkflowTaskLabel(task, index), + usesDynamicFileSyncTargets, + ); + }); if (payload.file_sync.enabled && !payload.file_sync.sources.length) { throw new Error("Select at least one File Sync source for this workflow."); } @@ -4711,8 +4937,8 @@ function initializeWorkflowEvents() { workflowFileSyncEnabledToggle?.addEventListener("change", updateFileSyncFields); workflowFileSyncWaitModeSelect?.addEventListener("change", updateFileSyncFields); workflowFileSyncContinueModeSelect?.addEventListener("change", updateFileSyncFields); - workflowDocumentActionTypeSelect?.addEventListener("change", updateDocumentActionFields); - workflowAnalysisTargetModeSelect?.addEventListener("change", updateDocumentActionFields); + workflowDocumentActionTypeSelect?.addEventListener("change", handleWorkflowDocumentActionSelectionChanged); + workflowAnalysisTargetModeSelect?.addEventListener("change", handleWorkflowDocumentActionSelectionChanged); workflowComparisonRightDocumentIdsInput?.addEventListener("change", () => { syncWorkflowComparisonLeftOptions(); }); diff --git a/application/single_app/templates/group_workspaces.html b/application/single_app/templates/group_workspaces.html index 18283a58e..12e2b08cf 100644 --- a/application/single_app/templates/group_workspaces.html +++ b/application/single_app/templates/group_workspaces.html @@ -2146,7 +2146,7 @@