diff --git a/application/single_app/config.py b/application/single_app/config.py index dc1de5f1b..0ea5593ff 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -97,7 +97,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.115" +VERSION = "0.261.116" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_document_analysis_checkpoints.py b/application/single_app/functions_document_analysis_checkpoints.py index b44487eb4..6dd364d1d 100644 --- a/application/single_app/functions_document_analysis_checkpoints.py +++ b/application/single_app/functions_document_analysis_checkpoints.py @@ -10,6 +10,7 @@ """ import hashlib +import json import uuid from copy import deepcopy @@ -118,7 +119,8 @@ def _lineage(self): seen = set() expected_child = None while binding is not None: - identity = tuple(sorted(binding.items())) + identity = tuple((key, json.dumps(value, sort_keys=True, separators=(',', ':'))) + for key, value in sorted(binding.items())) if identity in seen or len(seen) >= 128: raise WorkflowResultIntegrityError('Analysis checkpoint retry lineage is invalid.') seen.add(identity) @@ -243,12 +245,23 @@ def analysis_checkpoints_for_workflow( attempt_token=None, settings=None, store=None, source_authorizer=None, operation_request=None, operation_sources=None, recover_running_unit=None, + execution_id=None, node_id=None, attempt=None, iteration_path=None, ): - binding = _identity(workflow, run_id, task_id) + selectors = {} + if execution_id is not None: + selectors = {"execution_id": execution_id, "node_id": node_id, "attempt": attempt, + "iteration_path": [] if iteration_path is None else iteration_path} + binding = _identity(workflow, run_id, task_id, **selectors) + result_store = store or _configured_result_store(binding, settings=settings, for_write=True) + resume_from = _identity(workflow, resume_run_id, task_id) if resume_run_id else None + if selectors and attempt > 1: + prior_binding = _identity(workflow, run_id, task_id, **{**selectors, "attempt": attempt - 1}) + if result_store._analysis_guard(prior_binding) is not None: + resume_from = prior_binding return AnalysisWorkUnitCheckpoints( - store or _configured_result_store(binding, settings=settings, for_write=True), + result_store, binding, user_id=user_id, authorize=authorize, attempt_token=attempt_token, - resume_from=_identity(workflow, resume_run_id, task_id) if resume_run_id else None, + resume_from=resume_from, source_authorizer=source_authorizer, operation_request=operation_request, operation_sources=operation_sources, recover_running_unit=recover_running_unit, diff --git a/application/single_app/functions_group_workflows.py b/application/single_app/functions_group_workflows.py index 24a6cef20..0bcaeba20 100644 --- a/application/single_app/functions_group_workflows.py +++ b/application/single_app/functions_group_workflows.py @@ -503,7 +503,10 @@ def save_group_workflow(group_id, workflow_data, actor_user_id, user_info=None): reference, actor_user_id=actor_user_id, ) task_prompt = _normalize_text( - workflow_data.get('task_prompt') or (tasks[0].get('instructions') if tasks else ''), + workflow_data.get('task_prompt') or ( + workflow_name if definition_fields['definition_version'] == 3 + else tasks[0].get('instructions') if tasks else '' + ), 'Task prompt', required=True, ) diff --git a/application/single_app/functions_personal_workflows.py b/application/single_app/functions_personal_workflows.py index 407258183..943ace918 100644 --- a/application/single_app/functions_personal_workflows.py +++ b/application/single_app/functions_personal_workflows.py @@ -215,9 +215,15 @@ def _normalize_workflow_tasks( normalized_tasks = [] seen_task_ids = set() + structured = workflow_data.get('definition_version') == 3 for index, raw_task in enumerate(raw_tasks): if not isinstance(raw_task, dict): raise ValueError(f'Workflow task {index + 1} is invalid.') + if structured and raw_task.keys() - { + 'id', 'type', 'name', 'instructions', 'order', 'runner', 'document_action', 'inputs', + 'reference_ids', 'output_contract', 'approval', 'publication', + }: + raise ValueError('A structured task contains unsupported executable fields.') task_type = _normalize_text(raw_task.get('type') or 'instructions', 'Task type').lower() if task_type != 'instructions': @@ -235,7 +241,7 @@ def _normalize_workflow_tasks( publication = None if raw_task.get('publication') is not None: publication = normalize_workflow_publication(raw_task['publication']) - if index == 0: + if index == 0 and not structured: raise ValueError('Add an analysis task before its publication task.') publication_action = raw_task.get('document_action') if publication_action is not None and ( @@ -253,6 +259,10 @@ def _normalize_workflow_tasks( ) raw_runner = raw_task.get('runner') if isinstance(raw_task.get('runner'), dict) else {} + if structured and raw_runner.keys() - { + 'type', 'selected_agent', 'model_endpoint_id', 'model_id', 'model_provider', 'model_binding_summary', + }: + raise ValueError('A structured task runner contains unsupported executable fields.') runner_type = _normalize_text(raw_runner.get('type') or 'inherit', 'Task runner type').lower() if runner_type not in WORKFLOW_TASK_RUNNER_TYPES: raise ValueError(f'Workflow task {index + 1} has an unsupported runner type.') @@ -278,7 +288,7 @@ def _normalize_workflow_tasks( if callable(task_document_action_normalizer): raw_document_action = raw_task.get('document_action') - if not isinstance(raw_document_action, dict) and index == 0: + if not isinstance(raw_document_action, dict) and index == 0 and not structured: # 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 @@ -815,7 +825,10 @@ def save_personal_workflow(user_id, workflow_data, actor_user_id=None): for reference in definition_fields.get('reference_inputs', []): authorize_workflow_reference({'user_id': user_id}, reference, actor_user_id=modifying_user_id) task_prompt = _normalize_text( - workflow_data.get('task_prompt') or (tasks[0].get('instructions') if tasks else ''), + workflow_data.get('task_prompt') or ( + workflow_name if definition_fields['definition_version'] == 3 + else tasks[0].get('instructions') if tasks else '' + ), 'Task prompt', required=True, ) @@ -1103,7 +1116,7 @@ def is_public_workflow_run_item(item): private_types = ( "workflow_result_chunk", "chat_analysis_result_chunk", "orchestration_analysis_result_chunk", "analysis_work_unit_checkpoint", - "workflow_runtime_control", + "workflow_runtime_control", "workflow_runtime_journal", ) return isinstance(item, dict) and not any( item.get(field) in private_types for field in ("type", "item_type") @@ -1113,10 +1126,10 @@ def is_public_workflow_run_item(item): WORKFLOW_PUBLIC_RUN_ITEMS_FILTER = ( 'AND (NOT IS_DEFINED(c.type) OR c.type NOT IN ' '("workflow_result_chunk", "chat_analysis_result_chunk", ' - '"orchestration_analysis_result_chunk", "analysis_work_unit_checkpoint", "workflow_runtime_control")) ' + '"orchestration_analysis_result_chunk", "analysis_work_unit_checkpoint", "workflow_runtime_control", "workflow_runtime_journal")) ' 'AND (NOT IS_DEFINED(c.item_type) OR c.item_type NOT IN ' '("workflow_result_chunk", "chat_analysis_result_chunk", ' - '"orchestration_analysis_result_chunk", "analysis_work_unit_checkpoint", "workflow_runtime_control")) ' + '"orchestration_analysis_result_chunk", "analysis_work_unit_checkpoint", "workflow_runtime_control", "workflow_runtime_journal")) ' ) diff --git a/application/single_app/functions_saved_analysis.py b/application/single_app/functions_saved_analysis.py index 073a4fbdf..41952fcfc 100644 --- a/application/single_app/functions_saved_analysis.py +++ b/application/single_app/functions_saved_analysis.py @@ -18,6 +18,7 @@ from functions_generated_file_exports import build_saved_analysis_export from functions_workflow_context import WorkflowContextBudgetError, calculate_workflow_context_budget from functions_workflow_result_store import WorkflowResultStorageUnavailableError, _quota_bytes +from functions_workflow_runtime_store import WorkflowRuntimeConflict from functions_workflow_results import ( ANALYSIS_SOURCE_ACCESS_VERSION, WorkflowResultNotReadyError, @@ -172,18 +173,22 @@ def _load_authorized_workflow(user_id, binding): from functions_workflow_runner import _workflow_task_run_item_id group_id = binding.get("group_id") + item_id = ( + _workflow_task_run_item_id(binding["run_id"], binding["task_id"], binding["execution_id"]) + if binding.get("execution_id") else _workflow_task_run_item_id(binding["run_id"], binding["task_id"]) + ) if group_id: assert_group_role(user_id, group_id, allowed_roles=("Owner", "Admin", "DocumentManager", "User")) workflow = get_group_workflow(group_id, binding["workflow_id"]) run = get_group_workflow_run(group_id, binding["run_id"]) item = get_group_workflow_run_item( - binding["run_id"], _workflow_task_run_item_id(binding["run_id"], binding["task_id"]), + binding["run_id"], item_id, ) else: workflow = get_personal_workflow(user_id, binding["workflow_id"]) run = get_personal_workflow_run(user_id, binding["run_id"]) item = get_personal_workflow_run_item( - binding["run_id"], _workflow_task_run_item_id(binding["run_id"], binding["task_id"]), + binding["run_id"], item_id, ) if ( not workflow or workflow.get("id") != binding["workflow_id"] @@ -367,6 +372,8 @@ def workflow_saved_analysis_descriptor(summary, workflow, *, conversation_id, me "kind": "workflow", "workflow_id": workflow["id"], "run_id": producer["run_id"], "task_id": producer["task_id"], "group_id": workflow.get("group_id"), + **({key: producer[key] for key in ("node_id", "execution_id", "iteration_path", "attempt")} + if producer.get("execution_id") else {}), }, "record_count": summary.get("record_count"), "source_count": summary.get("source_count"), @@ -467,6 +474,15 @@ def analysis_artifact_metadata(producer): if not isinstance(value, str) or not value.strip() or len(value.encode("utf-8")) > 1024: raise ValueError("The analysis artifact producer is incomplete.") normalized[field] = value.strip() + if producer["kind"] == "workflow" and producer.get("execution_id"): + for field in ("node_id", "execution_id"): + value = producer.get(field) + if not isinstance(value, str) or not value or len(value) > 128: + raise ValueError("The exact analysis execution identity is incomplete.") + normalized[field] = value + if type(producer.get("attempt")) is not int or producer["attempt"] < 1 or producer.get("iteration_path") != []: + raise ValueError("The exact analysis attempt identity is incomplete.") + normalized.update(attempt=producer["attempt"], iteration_path=[]) return {"analysis_result_required": True, "analysis_producer": normalized} @@ -501,7 +517,10 @@ def _workflow_analysis_artifact_manifest(user_id, artifact, producer): from functions_personal_workflows import get_personal_workflow_run_item from functions_workflow_runner import _workflow_task_run_item_id - item_id = _workflow_task_run_item_id(producer["run_id"], producer["task_id"]) + item_id = ( + _workflow_task_run_item_id(producer["run_id"], producer["task_id"], producer["execution_id"]) + if producer.get("execution_id") else _workflow_task_run_item_id(producer["run_id"], producer["task_id"]) + ) items = [ item for item in ( get_personal_workflow_run_item(producer["run_id"], item_id), @@ -516,11 +535,25 @@ def _workflow_analysis_artifact_manifest(user_id, artifact, producer): binding = {**producer, "group_id": item.get("group_id")} workflow = _load_authorized_workflow(user_id, binding) summary = item.get("workflow_result") or {} + selectors = {} + if producer.get("execution_id"): + from functions_workflow_runtime_store import workflow_runtime_store + + journal = workflow_runtime_store(workflow, producer["run_id"]) + workflow = journal.run_definition() + row = journal.journal_read( + "attempt", [producer["execution_id"], producer["attempt"]], + ) + if row is None or row["payload"].get("task_id") != producer["task_id"] or row["payload"].get("node_id") != producer["node_id"]: + raise AnalysisResultUnavailable("analysis_artifact_unbound") + summary = row["payload"].get("workflow_result") or {} + selectors = {key: producer[key] for key in ("node_id", "execution_id", "iteration_path", "attempt")} reference = summary.get("result_ref") if not isinstance(reference, Mapping): raise AnalysisResultUnavailable("analysis_artifact_unbound") manifest, _ = authorize_workflow_task_result_read( workflow, producer["run_id"], producer["task_id"], reference, reader_user_id=user_id, + **selectors, ) if not any( value.get("artifact_message_id") == artifact.get("id") @@ -711,11 +744,21 @@ def load_saved_analysis( if not isinstance(binding.get(field), str) or not binding[field]: raise AnalysisResultUnavailable("analysis_lineage_invalid") workflow = (workflow_getter or _load_authorized_workflow)(user_id, binding) - loader = workflow_loader or _workflow_load - load = lambda ref: loader(workflow, binding["run_id"], binding["task_id"], ref) + selectors = {} + if binding.get("execution_id"): + from functions_workflow_result_store import load_workflow_node_result + from functions_workflow_runtime_store import workflow_runtime_store + + workflow = workflow_runtime_store(workflow, binding["run_id"]).run_definition() + selectors = {key: binding[key] for key in ("node_id", "execution_id", "iteration_path", "attempt")} + loader = workflow_loader or load_workflow_node_result + else: + loader = workflow_loader or _workflow_load + load = lambda ref: loader(workflow, binding["run_id"], binding["task_id"], ref, **selectors) manifest, checked = authorize_workflow_task_result_read( workflow, binding["run_id"], binding["task_id"], reference, reader_user_id=user_id, load_result=loader, source_resolver=source_resolver, + **selectors, ) elif binding.get("kind") == "orchestration": for field in ("user_id", "conversation_id", "run_id", "step_id"): @@ -1647,33 +1690,44 @@ def sanitize_workflow_analysis_history(workflow, run_record, user_id, *, items=N tasks = list(run_record.get("task_results") or []) + list(items or []) denied = set() checked = {} + bound_workflow = None for task in tasks: if not isinstance(task, Mapping): continue task_id = task.get("task_id") summary = task.get("workflow_result") or {} - candidates = [summary] if isinstance(summary, Mapping) and summary.get("analysis_result") else [] + candidates = [summary] if isinstance(summary, Mapping) and ( + summary.get("analysis_result") or summary.get("contract_version") == "workflow-result-v2" + ) else [] candidates.extend( consumed for consumed in task.get("consumed_inputs") or [] - if isinstance(consumed, Mapping) and consumed.get("analysis_result") + if isinstance(consumed, Mapping) and (consumed.get("analysis_result") or (consumed.get("producer") or {}).get("execution_id")) ) for candidate in candidates: producer = candidate.get("producer") or {} reference = candidate.get("result_ref") or {} - key = (producer.get("run_id"), producer.get("task_id"), reference.get("sha256")) + key = (producer.get("run_id"), producer.get("task_id"), producer.get("execution_id"), + producer.get("attempt"), reference.get("sha256")) if key not in checked: try: if ( producer.get("workflow_id") != workflow.get("id") or producer.get("run_id") != run_record.get("id") - or not producer.get("task_id") + or not producer.get("task_id") and not producer.get("execution_id") ): raise AnalysisResultUnavailable("analysis_lineage_invalid") + selectors = {} + if producer.get("execution_id"): + selectors = {name: producer.get(name) for name in ("node_id", "execution_id", "iteration_path", "attempt")} + if result_reader is None and bound_workflow is None: + from functions_workflow_runtime_store import workflow_runtime_store + + bound_workflow = workflow_runtime_store(workflow, producer["run_id"]).run_definition() read( - workflow, producer["run_id"], producer["task_id"], reference, - reader_user_id=user_id, + bound_workflow or workflow, producer["run_id"], producer.get("task_id"), reference, + reader_user_id=user_id, **selectors, ) - except (PermissionError, LookupError, ValueError, AzureError, WorkflowResultStorageUnavailableError) as exc: + except (PermissionError, LookupError, ValueError, AzureError, WorkflowResultStorageUnavailableError, WorkflowRuntimeConflict) as exc: log_event( "[DOCUMENT_ANALYSIS] Workflow analysis preview withheld.", extra={"run_id": run_record.get("id"), "task_id": task_id, "error_type": type(exc).__name__}, diff --git a/application/single_app/functions_workflow_activity.py b/application/single_app/functions_workflow_activity.py index 1a8a491bf..da3ede597 100644 --- a/application/single_app/functions_workflow_activity.py +++ b/application/single_app/functions_workflow_activity.py @@ -117,6 +117,7 @@ def _serialize_run(run_record): field: task.get(field) for field in ( 'task_id', 'task_name', 'task_order', 'status', + 'execution_id', 'node_id', 'iteration_path', 'attempt', 'workflow_result', 'context_budget', 'consumed_inputs', 'workflow_validation', ) } diff --git a/application/single_app/functions_workflow_definition_store.py b/application/single_app/functions_workflow_definition_store.py index 0a8af2368..b5a3a2fab 100644 --- a/application/single_app/functions_workflow_definition_store.py +++ b/application/single_app/functions_workflow_definition_store.py @@ -36,9 +36,10 @@ def save_workflow_definition_record(container, partition_key, workflow, existing raise WorkflowDefinitionConflict("This workflow is being deleted. Your draft was not saved.") if workflow_definition_revision(current) != expected: raise WorkflowDefinitionConflict("This workflow changed since it was opened. Reload it before saving.") - if workflow.get("definition_version") == 2 and current.get("active_run_id"): + if workflow.get("definition_version") in {2, 3} and current.get("active_run_id"): raise WorkflowDefinitionConflict("An active run started while editing. Wait or cancel it before saving.") - body = dict(workflow) + body = {key: value for key, value in current.items() if not key.startswith("_")} + body.update(workflow) body.update({key: current[key] for key in WORKFLOW_RUNTIME_FIELDS if key in current}) # A schedule edit intentionally computes a new next run; preserve the # live scheduler value only when its authored inputs stayed the same. diff --git a/application/single_app/functions_workflow_definitions.py b/application/single_app/functions_workflow_definitions.py index cee7ed7f3..1edb9817c 100644 --- a/application/single_app/functions_workflow_definitions.py +++ b/application/single_app/functions_workflow_definitions.py @@ -19,7 +19,7 @@ "trigger_type", "is_enabled", "schedule", "error_handling", "document_action", "analyze", "file_sync", "selected_agent", "model_endpoint_id", "model_id", "model_provider", "url_access_enabled", "alert_priority", "alert_mode", "alert_rules", "alert_evaluation", - "definition_version", "reference_inputs", "durable_execution", + "definition_version", "reference_inputs", "durable_execution", "flow", "limits", ) SCHEMA_KEYWORDS = frozenset({ "type", "properties", "required", "additionalProperties", "items", @@ -142,7 +142,7 @@ def normalize_workflow_output_contract(value): "kind", "schema", "expected_count", "identity_field", "require_complete_coverage", "allow_partial", }, "Output contract") kind = contract.get("kind", "any") - if kind not in WORKFLOW_OUTPUT_KINDS: + if not isinstance(kind, str) or kind not in WORKFLOW_OUTPUT_KINDS: raise WorkflowDefinitionError("Choose a supported output kind.") normalized = { "kind": kind, @@ -235,13 +235,26 @@ def normalize_workflow_definition(payload, existing, tasks, *, user_id, group_id existing = existing or {} version = payload.get("definition_version", 1) stored_version = existing.get("definition_version", 1) - if type(version) is not int or version not in {1, WORKFLOW_DEFINITION_VERSION}: + if type(version) is not int or version not in {1, 2, 3}: raise WorkflowDefinitionConflict("This workflow definition version is not supported by this editor.") - if type(stored_version) is not int or stored_version not in {1, WORKFLOW_DEFINITION_VERSION}: + if type(stored_version) is not int or stored_version not in {1, 2, 3}: raise WorkflowDefinitionConflict("This saved workflow requires a newer editor. Its definition was not changed.") - if stored_version == WORKFLOW_DEFINITION_VERSION and version != WORKFLOW_DEFINITION_VERSION: + if version == 3: + managed_fields = { + "id", "definition_revision", "conversation_id", "user_id", "group_id", + "url_access_authorized", "url_access_authorized_by", "url_access_authorized_at", + "model_binding_summary", "created_at", "created_by", "modified_at", "modified_by", + "updated_at", "status", "last_run_started_at", "last_run_at", "last_run_status", + "last_run_error", "last_run_response_preview", "last_run_trigger_source", "run_count", + "active_run_id", "active_runtime_version", "last_run_id", "next_run_at", + "cancellation_requested_at", "cancellation_requested_by", "result_access", + } + extras = payload.keys() - set(WORKFLOW_DEFINITION_FIELDS) - managed_fields + if any(key not in existing or payload[key] != existing[key] for key in extras): + raise WorkflowDefinitionError("The structured workflow contains unsupported fields.") + if stored_version >= 2 and version < stored_version: raise WorkflowDefinitionConflict("This workflow uses advanced data flow. Open it in V2 to edit without losing its configuration.") - if version == WORKFLOW_DEFINITION_VERSION and existing: + if version >= 2 and existing: if payload.get("definition_revision") != workflow_definition_revision(existing): raise WorkflowDefinitionConflict("This workflow changed since it was opened. Reload it before saving.") if existing.get("active_run_id"): @@ -249,7 +262,7 @@ def normalize_workflow_definition(payload, existing, tasks, *, user_id, group_id raw_tasks = payload.get("tasks", existing.get("tasks", [])) if len(raw_tasks) != len(tasks): raise WorkflowDefinitionError("Task data does not match the normalized task list.") - has_flow = "reference_inputs" in payload or payload.get("durable_execution") is True or any( + has_flow = "reference_inputs" in payload or "flow" in payload or payload.get("durable_execution") is True or any( WORKFLOW_FLOW_TASK_FIELDS.intersection(task) for task in raw_tasks ) if version == 1: @@ -268,7 +281,10 @@ def normalize_workflow_definition(payload, existing, tasks, *, user_id, group_id prepared = dict(task) if "output_contract" in raw and raw["output_contract"] is not None: prepared["output_contract"] = normalize_workflow_output_contract(raw["output_contract"]) - if "inputs" in raw and raw["inputs"] is not None: + if version == 3: + # The structured compiler owns node bindings; legacy predecessor rules do not apply. + prepared["inputs"] = raw.get("inputs", []) + elif "inputs" in raw and raw["inputs"] is not None: prepared["inputs"] = _normalize_bindings(raw["inputs"], earlier) if "reference_ids" in raw and raw["reference_ids"] is not None: selected_ids = _unique_identifiers(raw["reference_ids"], "Task reference ids") @@ -286,9 +302,18 @@ def normalize_workflow_definition(payload, existing, tasks, *, user_id, group_id prepared["approval"] = {"required": required, "message": message.strip()} normalized_tasks.append(prepared) earlier[prepared["id"]] = prepared - return { - "definition_version": WORKFLOW_DEFINITION_VERSION, + result = { + "definition_version": version, "reference_inputs": references, "durable_execution": durable, "tasks": normalized_tasks, } + if version == 3: + # The compiler shares definition helpers, so import at the normalization boundary. + from functions_workflow_flow import compile_workflow_flow + + compiled = compile_workflow_flow({**payload, **result}) + result.update({key: compiled[key] for key in ("flow", "tasks", "limits")}) + elif any(key in payload for key in ("flow", "limits", "max_executions", "deadline_seconds")): + raise WorkflowDefinitionError("Structured flow and run limits require definition version 3.") + return result diff --git a/application/single_app/functions_workflow_editor.py b/application/single_app/functions_workflow_editor.py index 083f7f300..e77f14eca 100644 --- a/application/single_app/functions_workflow_editor.py +++ b/application/single_app/functions_workflow_editor.py @@ -3,6 +3,7 @@ from functions_ai_connections import supports_model_capability from functions_workflow_definitions import WORKFLOW_DEFINITION_VERSION +from functions_workflow_flow import FLOW_LIMITS def build_workflow_editor_options(*, scope_type, scope_id, can_manage, max_tasks, @@ -43,6 +44,9 @@ def build_workflow_editor_options(*, scope_type, scope_id, can_manage, max_tasks default_model = default_model or {} return { "definition_version": WORKFLOW_DEFINITION_VERSION, + "supported_definition_versions": [1, 2, 3], + "supported_node_kinds": ["task", "if", "route"], + "flow_limits": dict(FLOW_LIMITS), "scope": {"type": scope_type, "id": str(scope_id)}, "can_manage": bool(can_manage), "max_tasks": max_tasks, diff --git a/application/single_app/functions_workflow_execution.py b/application/single_app/functions_workflow_execution.py index 194dd3cec..9c25606f8 100644 --- a/application/single_app/functions_workflow_execution.py +++ b/application/single_app/functions_workflow_execution.py @@ -65,10 +65,7 @@ def workflow_checkpoint_scope_guard(record): execution = current_workflow_execution() if execution is not None: execution.check() - reference = execution.save_result( - execution.workflow, execution.run_id, "runtime:run-record", - {"run_record": record}, settings=execution.settings, - ) + reference = execution.save_runtime_record(record) execution._update(execution.check(), {"run_record_ref": reference}) @@ -88,6 +85,14 @@ def __init__(self, store, lease, workflow, run_id, *, settings=None, def check(self): return self.lease.check() + def unit(self, key): + return deepcopy((self.check().get("units") or {}).get(key) or {}) + + def save_runtime_record(self, record): + return self.save_result( + self.workflow, self.run_id, "runtime:run-record", {"run_record": record}, settings=self.settings, + ) + def _update(self, record, updates): return self.store.update(self.lease.token, updates, expected_version=record["version"]) diff --git a/application/single_app/functions_workflow_execution_history.py b/application/single_app/functions_workflow_execution_history.py new file mode 100644 index 000000000..cdac7af16 --- /dev/null +++ b/application/single_app/functions_workflow_execution_history.py @@ -0,0 +1,99 @@ +# functions_workflow_execution_history.py +"""Authorized safe projections of the schema-2 execution journal.""" + +from functions_analysis_access import authorize_analysis_sources, build_analysis_access +from functions_workflow_identity import workflow_node_identity +from functions_workflow_node_results import authorize_workflow_node_result_read, result_selectors +from functions_workflow_result_store import read_workflow_node_result_page +from functions_workflow_runtime_store import workflow_runtime_store + + +def authorize_execution_payload(workflow, run_id, payload, *, reader_user_id): + references = payload.get("reference_sources") or [] + if references: + policy = build_analysis_access(references) + authorize_analysis_sources(reader_user_id, policy["sources"]) + summary = payload.get("workflow_result") or {} + if summary.get("result_ref"): + authorize_workflow_node_result_read( + workflow, run_id, summary["producer"], summary["result_ref"], reader_user_id=reader_user_id, + ) + for receipt in payload.get("consumed_inputs") or []: + authorize_workflow_node_result_read( + workflow, run_id, receipt["producer"], receipt["result_ref"], reader_user_id=reader_user_id, + ) + + +def workflow_execution_history(workflow, run_id, *, reader_user_id, kind="execution", execution_id=None, + cursor=None, limit=50): + store = workflow_runtime_store(workflow, run_id) + if store.read().get("schema_version") != 2: + raise ValueError("Execution history is available only for structured workflow runs.") + workflow = store.run_definition() + if execution_id: + execution = store.journal_read("execution", execution_id) + if execution is None: + raise LookupError("Execution not found.") + page = store.journal_page(kind, execution_id=execution_id, cursor=cursor, limit=limit) + # Read the bound internal records too: safe decision projections intentionally omit source references. + for item in page["items"]: + if kind == "decision": + key = ["gate", item["gate_id"]] if item.get("gate_id") else ["control", item["execution_id"]] + row = store.journal_read("decision", key) + payload = row["payload"] + else: + key = item["execution_id"] if kind == "execution" else [item["execution_id"], item["attempt"]] + row = store.journal_read(kind, key) + if row is None: + raise LookupError("The execution journal changed while it was being read.") + payload = row["payload"] + authorize_execution_payload(workflow, run_id, payload, reader_user_id=reader_user_id) + name = {"execution": "executions", "attempt": "attempts", "decision": "decisions"}[kind] + result = {name: page["items"], "next_cursor": page["next_cursor"]} + if kind == "execution": + result["total_count"] = page["total_count"] + return result + + +def workflow_execution_result_page(workflow, run_id, execution_id, attempt, *, reader_user_id, + output="authoritative", offset=0, limit=2000): + store = workflow_runtime_store(workflow, run_id) + workflow = store.run_definition() + record = store.journal_read("attempt", [execution_id, attempt]) + if record is None: + raise LookupError("Execution attempt not found.") + payload = record["payload"] + summary = payload.get("workflow_result") or {} + identity = summary.get("producer") or {} + expected = workflow_node_identity( + workflow, run_id, payload["node_id"], execution_id, attempt, task_id=payload.get("task_id"), iteration_path=[], + ) + if identity != expected or not summary.get("result_ref"): + raise ValueError("This exact attempt has no saved result.") + manifest, _ = authorize_workflow_node_result_read( + workflow, run_id, identity, summary["result_ref"], reader_user_id=reader_user_id, + ) + name = manifest.get("authoritative_output") if output == "authoritative" else output + reference = summary["result_ref"] if name == "manifest" else (manifest.get("outputs", {}).get(name) or {}).get("result_ref") + if not reference: + raise LookupError("The selected output was not produced.") + descriptor = (manifest.get("outputs") or {}).get(name) or {} + for _ in range(256): + selected = descriptor.get("selected_producer") + if not selected or name == "manifest": + break + identity = selected["producer"] + manifest, _ = authorize_workflow_node_result_read( + workflow, run_id, identity, selected["result_ref"], reader_user_id=reader_user_id, + ) + descriptor = (manifest.get("outputs") or {}).get(selected["output_name"]) or {} + if descriptor.get("result_ref") != selected["output_ref"]: + raise ValueError("The selected producer output changed.") + reference = descriptor["result_ref"] + else: + raise ValueError("The selected producer lineage is invalid.") + return { + **read_workflow_node_result_page( + workflow, run_id, identity.get("task_id"), reference, **result_selectors(identity), offset=offset, limit=limit, + ), "output_name": name, + } diff --git a/application/single_app/functions_workflow_flow.py b/application/single_app/functions_workflow_flow.py new file mode 100644 index 000000000..e8823f6cd --- /dev/null +++ b/application/single_app/functions_workflow_flow.py @@ -0,0 +1,495 @@ +# functions_workflow_flow.py +"""Bounded structured workflow compiler and deterministic, data-only predicates.""" + +import json +import math +import re +from copy import deepcopy + +from functions_workflow_definitions import ( + WORKFLOW_BINDABLE_OUTPUTS, WORKFLOW_OUTPUT_KINDS, WorkflowDefinitionError, + _boolean, _name, _object, normalize_workflow_output_contract, workflow_output_kind_matches, +) + + +FLOW_LIMITS = { + "max_nodes": 256, "max_depth": 4, "max_predicate_nodes": 100, + "max_predicate_depth": 8, "max_executions": 5000, "deadline_seconds": 86400, +} +MISSING = object() +_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}\Z") +_BAD_POINTER_ESCAPE = re.compile(r"~(?![01])") + + +def _id(value): + if not isinstance(value, str) or not _ID.fullmatch(value): + raise WorkflowDefinitionError("Flow ids must be stable letters, numbers, dots, colons, underscores or hyphens.") + return value + + +def normalize_flow_bindings(values): + if not isinstance(values, list) or len(values) > 100: + raise WorkflowDefinitionError("Flow inputs must be a list of at most 100 bindings; null is not supported.") + result, names = [], set() + for value in values: + binding = _object(value, {"name", "source", "required", "expected_kind", "allow_partial"}, "Flow binding") + name = _name(binding.get("name"), "Binding name") + if name in names: + raise WorkflowDefinitionError("Binding names must be unique.") + names.add(name) + source = _object(binding.get("source"), {"kind", "node_id", "output", "scope"}, "Binding source") + if source.get("kind") != "node_output" or source.get("scope", "current") != "current": + raise WorkflowDefinitionError("M4A bindings support only node_output in the current scope.") + output = source.get("output", "authoritative") + if not isinstance(output, str) or not output or len(output) > 64: + raise WorkflowDefinitionError("A binding output selector is required.") + kind = binding.get("expected_kind", "any") + if not isinstance(kind, str) or kind not in WORKFLOW_OUTPUT_KINDS: + raise WorkflowDefinitionError("Unsupported binding output kind.") + result.append({ + "name": name, + "source": {"kind": "node_output", "node_id": _id(source.get("node_id")), + "output": output, "scope": "current"}, + "required": _boolean(binding.get("required", True), "Required input"), + "expected_kind": kind, + "allow_partial": _boolean(binding.get("allow_partial", False), "Partial input"), + }) + return result + + +def normalize_predicate(value, bindings): + try: + encoded = json.dumps(value, allow_nan=False, ensure_ascii=True) + except (ValueError, TypeError, RecursionError) as exc: + raise WorkflowDefinitionError("A condition must be finite JSON.") from exc + if len(encoded) > 16384: + raise WorkflowDefinitionError("A condition must be at most 16 KiB.") + names = {binding["name"] for binding in bindings} + count = 0 + + def operand(raw, *, reference_only=False): + nonlocal count + count += 1 + if count > 100: + raise WorkflowDefinitionError("Conditions are limited to 100 AST nodes.") + allowed = {"input", "path"} if reference_only else {"input", "path", "literal"} + _object(raw, allowed, "Condition operand") + if "literal" in raw: + literal = raw["literal"] + if len(raw) != 1 or not ( + literal is None or type(literal) in {str, bool, int} + or type(literal) is float and math.isfinite(literal) + ): + raise WorkflowDefinitionError("Condition literals must be finite JSON scalars.") + return {"literal": literal} + path = raw.get("path", "") + if not isinstance(raw.get("input"), str) or raw["input"] not in names: + raise WorkflowDefinitionError("A condition must reference a declared input name.") + if not isinstance(path, str) or (path and not path.startswith("/")) or _BAD_POINTER_ESCAPE.search(path): + raise WorkflowDefinitionError("Condition paths must be RFC 6901 JSON pointers.") + return {"input": raw["input"], "path": path} + + def visit(raw, depth=1): + nonlocal count + count += 1 + if count > 100 or depth > 8 or not isinstance(raw, dict): + raise WorkflowDefinitionError("Conditions are limited to 100 AST nodes and depth 8.") + op = raw.get("op") + if not isinstance(op, str): + raise WorkflowDefinitionError("A condition requires a supported operator.") + if op in {"all", "any"}: + _object(raw, {"op", "conditions"}, "Condition") + children = raw.get("conditions") + if not isinstance(children, list) or not children: + raise WorkflowDefinitionError("All/any conditions require a nonempty conditions list.") + return {"op": op, "conditions": [visit(child, depth + 1) for child in children]} + if op == "not": + _object(raw, {"op", "condition"}, "Condition") + return {"op": op, "condition": visit(raw.get("condition"), depth + 1)} + if op == "exists": + _object(raw, {"op", "value"}, "Condition") + return {"op": op, "value": operand(raw.get("value"), reference_only=True)} + if op in {"eq", "ne", "lt", "lte", "gt", "gte"}: + _object(raw, {"op", "left", "right"}, "Condition") + return {"op": op, "left": operand(raw.get("left")), "right": operand(raw.get("right"))} + raise WorkflowDefinitionError("Unsupported condition operator.") + + return visit(value) + + +def evaluate_predicate(predicate, values): + """Values must already have passed authorization and structural validation.""" + def resolve(operand): + if "literal" in operand: + return operand["literal"] + value = values.get(operand["input"], MISSING) + pointer = operand["path"] + for token in pointer.split("/")[1:] if pointer else []: + token = token.replace("~1", "/").replace("~0", "~") + if isinstance(value, dict): + value = value.get(token, MISSING) + elif isinstance(value, list) and re.fullmatch(r"0|[1-9][0-9]*", token): + index = int(token) + value = value[index] if index < len(value) else MISSING + else: + value = MISSING + return value + + def json_type(value): + if type(value) in {int, float}: + return "number" + return type(value) + + def equal(left, right): + if json_type(left) != json_type(right): + return False + if isinstance(left, dict): + return left.keys() == right.keys() and all(equal(left[key], right[key]) for key in left) + if isinstance(left, list): + return len(left) == len(right) and all(equal(a, b) for a, b in zip(left, right)) + return left == right + + def visit(node): + op = node["op"] + if op == "all": + return all(visit(child) for child in node["conditions"]) + if op == "any": + return any(visit(child) for child in node["conditions"]) + if op == "not": + return not visit(node["condition"]) + if op == "exists": + return resolve(node["value"]) is not MISSING + left, right = resolve(node["left"]), resolve(node["right"]) + if left is MISSING or right is MISSING: + raise WorkflowDefinitionError("A condition referenced missing data without an exists guard.") + if op in {"eq", "ne"}: + if any(isinstance(value, (dict, list)) for value in (left, right)): + raise WorkflowDefinitionError("Condition comparisons require scalar fields.") + return equal(left, right) if op == "eq" else not equal(left, right) + if not all(type(value) is int or type(value) is float and math.isfinite(value) for value in (left, right)): + raise WorkflowDefinitionError("Ordered condition comparisons require finite numbers.") + return {"lt": lambda: left < right, "lte": lambda: left <= right, + "gt": lambda: left > right, "gte": lambda: left >= right}[op]() + + return visit(predicate) + + +def compile_workflow_flow(workflow): + """Normalize and prove lexical visibility and definite availability on every path.""" + if workflow.get("definition_version") != 3 or workflow.get("durable_execution") is not True: + raise WorkflowDefinitionError("Structured workflows require definition version 3 and durable execution.") + tasks = workflow.get("tasks") + if not isinstance(tasks, list) or not tasks or len(tasks) > 100: + raise WorkflowDefinitionError("A workflow catalogue requires 1 to 100 tasks.") + catalogue = {} + for task in tasks: + if not isinstance(task, dict): + raise WorkflowDefinitionError("A task catalogue entry must be an object.") + if task.keys() - { + "id", "type", "name", "instructions", "order", "runner", "document_action", "inputs", + "reference_ids", "output_contract", "approval", "publication", + }: + raise WorkflowDefinitionError("A catalogue task contains unsupported executable fields.") + if task.get("type", "instructions") != "instructions": + raise WorkflowDefinitionError("The task catalogue supports only existing instruction tasks.") + instructions = task.get("instructions") + if not isinstance(instructions, str) or not instructions.strip() or len(instructions) > 12000: + raise WorkflowDefinitionError("Task instructions must be nonempty text of at most 12000 characters.") + runner = task.get("runner", {"type": "inherit"}) + if not isinstance(runner, dict) or runner.get("type", "inherit") not in ("inherit", "model", "agent"): + raise WorkflowDefinitionError("A task requires an existing workflow runner.") + _object(runner, { + "type", "selected_agent", "model_endpoint_id", "model_id", "model_provider", "model_binding_summary", + }, "Task runner") + identifier = _id(task.get("id")) + if identifier in catalogue: + raise WorkflowDefinitionError("Task catalogue ids must be unique.") + catalogue[identifier] = {**deepcopy(task), "inputs": normalize_flow_bindings(task.get("inputs", []))} + if task.get("output_contract") is not None: + catalogue[identifier]["output_contract"] = normalize_workflow_output_contract(task["output_contract"]) + if task.get("approval") is not None: + approval = _object(task["approval"], {"required", "message"}, "Task approval") + _boolean(approval.get("required", False), "Task approval requirement") + if not isinstance(approval.get("message", ""), str) or len(approval.get("message", "")) > 1000: + raise WorkflowDefinitionError("Task approval text must be at most 1000 characters.") + error_handling = workflow.get("error_handling") or {} + _object(error_handling, {"strategy", "retry_count"}, "Error handling") + retries = error_handling.get("retry_count", 0) + if type(retries) is not int or not 0 <= retries <= 5 or error_handling.get("strategy", "halt") not in ("halt", "continue"): + raise WorkflowDefinitionError("Error handling supports halt/continue with zero to five retries.") + if any(key in workflow for key in ("max_executions", "deadline_seconds")): + raise WorkflowDefinitionError("Run budgets belong in the explicit limits object.") + raw_limits = _object(workflow.get("limits", {}), {"max_executions", "deadline_seconds"}, "Run limits") + limits = {} + for key in ("max_executions", "deadline_seconds"): + value = raw_limits.get(key, FLOW_LIMITS[key]) + if type(value) is not int or not 1 <= value <= FLOW_LIMITS[key]: + raise WorkflowDefinitionError(f"{key} must be an integer between 1 and {FLOW_LIMITS[key]}.") + limits[key] = value + ids, nodes, regions, task_nodes, successor, dependencies = set(), {}, {}, {}, {}, {} + + def register(identifier): + identifier = _id(identifier) + if identifier in ids or len(ids) >= FLOW_LIMITS["max_nodes"]: + raise WorkflowDefinitionError("All flow region, node and join ids must be unique; at most 256 are supported.") + ids.add(identifier) + return identifier + + def region(raw, depth, *, root=False, parent=None): + if depth > 4: + raise WorkflowDefinitionError("Flow regions are limited to depth 4.") + _object(raw, {"id", "nodes", "outputs"} if root else {"id", "nodes"}, "Flow region") + result = {"id": register(raw.get("id")), "nodes": []} + children = raw.get("nodes") + if not isinstance(children, list) or len(children) > 256: + raise WorkflowDefinitionError("A flow region requires a bounded nodes list.") + regions[result["id"]] = {"region": result, "parent": parent} + for child in children: + if not isinstance(child, dict): + raise WorkflowDefinitionError("A flow node must be an object.") + kind = child.get("kind") + allowed = { + "task": {"id", "kind", "task_id", "run_when"}, + "if": {"id", "kind", "inputs", "condition", "then", "else", "join"}, + "route": {"id", "kind", "inputs", "condition", "target"}, + } + if not isinstance(kind, str) or kind not in allowed: + raise WorkflowDefinitionError("Only task, if and route nodes are executable in M4A.") + _object(child, allowed[kind], "Flow node") + node = {"id": register(child.get("id")), "kind": kind} + nodes[node["id"]] = {"node": node, "region_id": result["id"]} + if kind == "task": + task_id = _id(child.get("task_id")) + if task_id not in catalogue or task_id in task_nodes: + raise WorkflowDefinitionError("Each catalogue task must occur exactly once in the flow.") + task_nodes[task_id] = node["id"] + node["task_id"] = task_id + if "run_when" in child: + node["run_when"] = normalize_predicate(child["run_when"], catalogue[task_id]["inputs"]) + else: + node["inputs"] = normalize_flow_bindings(child.get("inputs")) + node["condition"] = normalize_predicate(child.get("condition"), node["inputs"]) + if kind == "route": + target = _object(child.get("target"), {"node_id", "exit_region_id"}, "Route target") + if len(target) != 1: + raise WorkflowDefinitionError("A route needs one explicit forward or region-exit target.") + node["target"] = {key: _id(value) for key, value in target.items()} + else: + node["then"] = region(child.get("then"), depth + 1, parent=node["id"]) + node["else"] = region(child.get("else"), depth + 1, parent=node["id"]) + join = _object(child.get("join"), {"id", "exports"}, "If join") + join_id = register(join.get("id")) + exports = join.get("exports") + if not isinstance(exports, list) or len(exports) > 100: + raise WorkflowDefinitionError("A join requires an exports list of at most 100 entries.") + normalized, names = [], set() + for export in exports: + _object(export, {"name", "expected_kind", "required", "then", "else"}, "Join export") + name = _name(export.get("name"), "Join export name") + expected = export.get("expected_kind", "any") + if name in names or not isinstance(expected, str) or expected not in WORKFLOW_OUTPUT_KINDS: + raise WorkflowDefinitionError("Join exports require unique names and a supported kind.") + names.add(name) + entry = {"name": name, "expected_kind": expected, + "required": _boolean(export.get("required", True), "Required join export")} + for branch in ("then", "else"): + source = _object(export.get(branch), {"node_id", "output"}, "Join producer") + output = source.get("output", "authoritative") + if not isinstance(output, str) or not output or len(output) > 64: + raise WorkflowDefinitionError("A join needs an exact output selector.") + entry[branch] = {"node_id": _id(source.get("node_id")), "output": output} + normalized.append(entry) + node["join"] = {"id": join_id, "exports": normalized} + nodes[join_id] = {"node": {"id": join_id, "kind": "join", "exports": normalized}, + "region_id": result["id"], "if_id": node["id"]} + result["nodes"].append(node) + if root: + if "outputs" not in raw: + raise WorkflowDefinitionError("The root flow must declare outputs, including an explicit empty list.") + result["outputs"] = normalize_flow_bindings(raw["outputs"]) + return result + + flow = region(workflow.get("flow"), 1, root=True) + if set(catalogue) != set(task_nodes): + raise WorkflowDefinitionError("Every catalogue task must occur exactly once in the executable flow.") + + def descriptor(node_id, output): + entry = nodes.get(node_id) + if not entry: + raise WorkflowDefinitionError("A binding references a missing producer node.") + node = entry["node"] + if node["kind"] == "join": + export = next((item for item in node["exports"] if item["name"] == output), None) + if export is None: + raise WorkflowDefinitionError("The selected join output is not declared.") + return export["expected_kind"] + if node["kind"] != "task" or output not in WORKFLOW_BINDABLE_OUTPUTS: + raise WorkflowDefinitionError("Only task final representations and declared join exports can supply inputs.") + contract = catalogue[node["task_id"]].get("output_contract") or {} + declared = contract.get("kind", "any") + if output not in {"authoritative", "text"} and declared not in { + "any", {"records": "records", "json": "json", "documents": "document_results"}[output], + }: + raise WorkflowDefinitionError("The selected representation does not match its producer's output contract.") + kind = {"text": "text", "records": "records", "json": "json", "documents": "document_results"}.get( + output, contract.get("kind", "any"), + ) + return kind + + def check_bindings(bindings, definite, possible, consumer): + dependencies.setdefault(consumer, []) + for binding in bindings: + source = binding["source"] + key = (source["node_id"], source["output"]) + kind = descriptor(*key) + if key not in possible: + raise WorkflowDefinitionError("An input references a future, unreachable or out-of-region producer.") + if binding["required"] and key not in definite: + raise WorkflowDefinitionError("A required producer is unavailable on a reachable path; use an optional input or join export.") + if kind != "any" and not workflow_output_kind_matches(kind, binding["expected_kind"]): + raise WorkflowDefinitionError("A binding's expected kind does not match its producer.") + dependencies[consumer].append(key) + + def task_keys(node): + declared = (catalogue[node["task_id"]].get("output_contract") or {}).get("kind", "any") + selectors = {"authoritative", "text"} + selectors.update({"json": {"json"}, "records": {"records"}, "document_results": {"documents"}}.get(declared, set())) + return {(node["id"], output) for output in selectors} + + def structured_output(node_id, output, active=None): + active = set() if active is None else set(active) + key = (node_id, output) + if key in active: + return False + active.add(key) + node = nodes[node_id]["node"] + if node["kind"] == "join": + export = next(item for item in node["exports"] if item["name"] == output) + return all(structured_output(export[branch]["node_id"], export[branch]["output"], active) + for branch in ("then", "else")) + contract = catalogue[node["task_id"]].get("output_contract") or {} + schema = contract.get("schema") or {} + root_types = schema.get("type") + root_types = {root_types} if isinstance(root_types, str) else set(root_types or []) + return ( + bool(root_types) and root_types <= {"object", "array"} + and contract.get("kind", "any") in {"any", "json", "records", "document_results"} + and output in {"authoritative", "json", "records", "documents"} + ) + + def check_predicate(predicate, bindings): + used = {binding["name"]: binding for binding in bindings} + + def schemas(node_id, output): + node = nodes[node_id]["node"] + if node["kind"] == "join": + export = next(item for item in node["exports"] if item["name"] == output) + return [schema for branch in ("then", "else") + for schema in schemas(export[branch]["node_id"], export[branch]["output"])] + return [catalogue[node["task_id"]]["output_contract"]["schema"]] + + def operand_types(operand, *, scalar=True): + if "literal" in operand: + value = operand["literal"] + return [{"null" if value is None else "boolean" if type(value) is bool + else "number" if type(value) in {int, float} else "string"}] + source = used[operand["input"]]["source"] + if not structured_output(source["node_id"], source["output"]): + raise WorkflowDefinitionError("Conditions require JSON or record fields with an explicit schema on every possible producer.") + types = [] + for schema in schemas(source["node_id"], source["output"]): + field = schema + for token in operand["path"].split("/")[1:] if operand["path"] else []: + token = token.replace("~1", "/").replace("~0", "~") + if field.get("type") == "array" and re.fullmatch(r"0|[1-9][0-9]*", token): + field = field.get("items") + else: + field = field.get("properties", {}).get(token) + if not isinstance(field, dict): + raise WorkflowDefinitionError("A condition field must be declared in every possible producer schema.") + field_type = field.get("type") + selected = {field_type} if isinstance(field_type, str) else set(field_type or []) + if not selected or scalar and not selected <= {"string", "boolean", "number", "integer", "null"}: + raise WorkflowDefinitionError("Condition comparisons require explicitly typed scalar fields.") + types.append(selected) + return types + + def visit(node): + if node["op"] in {"all", "any"}: + for child in node["conditions"]: + visit(child) + elif node["op"] == "not": + visit(node["condition"]) + elif node["op"] == "exists": + operand_types(node["value"], scalar=False) + else: + types = operand_types(node["left"]) + operand_types(node["right"]) + if node["op"] in {"lt", "lte", "gt", "gte"} and any( + not values.intersection({"number", "integer"}) for values in types + ): + raise WorkflowDefinitionError("Ordered conditions require numeric fields and literal values.") + + visit(predicate) + + def intersect(states): + return set.intersection(*(state[0] for state in states)), set.union(*(state[1] for state in states)) + + def analyze(current, initial, *, branch=False): + children = current["nodes"] + positions = {node["id"]: index for index, node in enumerate(children)} + incoming = {0: [initial]} + exits = [] + for index, node in enumerate(children): + if index not in incoming: + raise WorkflowDefinitionError("The flow contains an unreachable node.") + definite, possible = intersect(incoming[index]) + after_definite, after_possible = set(definite), set(possible) + kind = node["kind"] + bindings = catalogue[node["task_id"]]["inputs"] if kind == "task" else node["inputs"] + check_bindings(bindings, definite, possible, node["id"]) + if kind != "task" or "run_when" in node: + check_predicate(node["run_when"] if kind == "task" else node["condition"], bindings) + if kind == "task": + keys = task_keys(node) + after_possible.update({(node["id"], output) for output in WORKFLOW_BINDABLE_OUTPUTS}) + if "run_when" not in node: + after_definite.update(keys) + if catalogue[node["task_id"]].get("publication") is not None and len(bindings) != 1: + raise WorkflowDefinitionError("A v3 publication task requires exactly one explicit upstream input.") + elif kind == "if": + ends = {name: analyze(node[name], (set(definite), set(possible)), branch=True) + for name in ("then", "else")} + for export in node["join"]["exports"]: + for name in ("then", "else"): + producer = export[name] + binding = {"name": export["name"], "source": producer, "required": export["required"], + "expected_kind": export["expected_kind"]} + check_bindings([binding], *ends[name], node["join"]["id"]) + key = (node["join"]["id"], export["name"]) + after_possible.add(key) + if export["required"]: + after_definite.add(key) + elif kind == "route": + target = node["target"] + if "exit_region_id" in target: + if not branch or target["exit_region_id"] != current["id"]: + raise WorkflowDefinitionError("A region exit may target only its current structured branch join.") + exits.append((set(definite), set(possible))) + else: + destination = positions.get(target["node_id"]) + if destination is None or destination <= index: + raise WorkflowDefinitionError("Routes may target only a later sibling node.") + incoming.setdefault(destination, []).append((set(definite), set(possible))) + successor[node["id"]] = children[index + 1]["id"] if index + 1 < len(children) else None + incoming.setdefault(index + 1, []).append((after_definite, after_possible)) + exits.extend(incoming.get(len(children), [])) + return intersect(exits or [initial]) + + definite, possible = analyze(flow, (set(), set())) + check_bindings(flow["outputs"], definite, possible, flow["id"]) + return { + "flow": flow, "tasks": list(catalogue.values()), "limits": limits, + "nodes": nodes, "regions": regions, "task_nodes": task_nodes, + "successor": successor, "dependencies": dependencies, + "definite_outputs": definite, "possible_outputs": possible, + } diff --git a/application/single_app/functions_workflow_flow_runner.py b/application/single_app/functions_workflow_flow_runner.py new file mode 100644 index 000000000..594b67bb0 --- /dev/null +++ b/application/single_app/functions_workflow_flow_runner.py @@ -0,0 +1,299 @@ +# functions_workflow_flow_runner.py +"""Structured traversal around the existing task dispatcher, with frozen decisions.""" + +import json +from copy import deepcopy + +from functions_workflow_bindings import WorkflowInputError +from functions_analysis_access import AnalysisResultUnavailable +from functions_workflow_definitions import workflow_output_kind_matches +from functions_workflow_flow import MISSING, compile_workflow_flow, evaluate_predicate +from functions_workflow_identity import canonical_digest, workflow_node_identity +from functions_workflow_node_results import load_workflow_node_input +from functions_workflow_results import _build_task_result, persist_workflow_task_result, workflow_result_summary + + +class WorkflowFlowRunner: + def __init__(self, workflow, run_id, execution, task_results, *, actor_user_id, settings=None, + load_output=load_workflow_node_input, persist=persist_workflow_task_result): + self.workflow = workflow + self.run_id = run_id + self.execution = execution + self.task_results = task_results + self.actor_user_id = actor_user_id + self.settings = settings or {} + self.load_output = load_output + self.persist = persist + self.compiled = compile_workflow_flow(workflow) + self.catalogue = {task["id"]: task for task in self.compiled["tasks"]} + self.completed = {} + self.final_outputs = [] + self.partial = False + self.failed = False + self.finished = False + self.control_receipts = [] + + @staticmethod + def _receipts(values): + return list({canonical_digest(value): value for value in values}.values()) + + @staticmethod + def _control_receipt(summary): + name = "decision" if "decision" in summary["outputs"] else summary["authoritative_output"] + return {"producer": summary["producer"], "result_ref": summary["result_ref"], + "output_name": name, "output_ref": summary["outputs"][name]["result_ref"], + "control": True} + + def resolve(self, bindings, *, condition=None): + inputs, receipts, values = [], [], {} + for binding in bindings: + source = binding["source"] + producer = self.completed.get(source["node_id"]) + if producer is None or producer.get("state") == "skipped": + if binding["required"]: + raise WorkflowInputError("A required producer was intentionally skipped or did not finish.") + inputs.append({"name": binding["name"], "status": "unavailable"}) + values[binding["name"]] = MISSING + continue + if producer.get("state") not in {"succeeded", "completed", "completed_partial"}: + raise WorkflowInputError("The selected producer is failed, invalid or pending, not optional absence.") + summary = producer["summary"] + try: + prompt, receipt = self.load_output( + self.workflow, self.run_id, summary["producer"], summary["result_ref"], + output_name=source["output"], allow_partial=binding["allow_partial"], + reader_user_id=self.actor_user_id, required=binding["required"], + ) + except AnalysisResultUnavailable: + from functions_workflow_execution import execution_fingerprint + + self.execution._pause(self.execution.node["id"], execution_fingerprint(summary)) + if prompt is None: + inputs.append({"name": binding["name"], "status": "unavailable"}) + values[binding["name"]] = MISSING + continue + payload = json.loads(prompt) + if not workflow_output_kind_matches(payload["kind"], binding["expected_kind"]): + raise WorkflowInputError("The input does not match its declared output kind.") + if condition is not None and binding["name"] in condition: + validation = summary.get("workflow_validation") or {} + if validation.get("eligible") is not True or not producer.get("structured_validated"): + raise WorkflowInputError("Conditions require structurally validated output fields.") + if (summary.get("workflow_validation") or {}).get("status") == "accepted_partial": + self.partial = True + values[binding["name"]] = payload["value"] + inputs.append({"name": binding["name"], "status": "available", "result": payload}) + receipts.append({**receipt, "input_name": binding["name"]}) + return { + "task_context": json.dumps({"inputs": inputs}, ensure_ascii=False, sort_keys=True) if inputs else "", + "consumed_inputs": self._receipts([*receipts, *self.control_receipts]), + "bound_inputs": receipts, "values": values, + } + + @staticmethod + def _predicate_names(predicate): + names, pending = set(), [predicate] + while pending: + value = pending.pop() + if isinstance(value, dict): + if "input" in value: + names.add(value["input"]) + pending.extend(value.values()) + elif isinstance(value, list): + pending.extend(value) + return names + + def _admit_control(self, node): + admitted_by_condition = node["kind"] == "task" and self.execution.store.journal_read( + "decision", ["control", self.execution.execution_id()], + ) is not None + self.execution.store.journal_commit( + self.execution.lease.token, "admission", [self.execution.execution_id(), 1], + {"execution_id": self.execution.execution_id(), "attempt": 1}, + admission=not admitted_by_condition, immutable=True, + updates={"cursor": {"region_id": self.execution.region_id, "node_id": node["id"]}, "phase": node["id"]}, + ) + + def _control_result(self, node, choice, receipts): + identity = workflow_node_identity( + self.workflow, self.run_id, node["id"], self.execution.execution_id(), 1, + task_id=node.get("task_id"), + ) + envelope = _build_task_result( + {"reply": "", "authoritative_result": {"kind": "json", "value": choice}}, + identity, "workflow-result-v2", + ) + envelope["consumed_inputs"] = receipts + envelope["workflow_validation"] = {"version": 1, "status": "valid", "eligible": True, "reason_codes": []} + if node["kind"] == "task": + envelope["outputs"] = {"decision": envelope["outputs"]["json"]} + envelope["authoritative_output"] = None + envelope["execution"]["status"] = "skipped" if choice["choice"] == "skip" else "succeeded" + manifest, reference = self.persist( + envelope, workflow=self.workflow, run_id=self.run_id, task_id=node.get("task_id"), settings=self.settings, + ) + return workflow_result_summary(manifest, reference) + + def _decision(self, node, inputs, choose): + from functions_workflow_execution import execution_fingerprint + + digest = execution_fingerprint({"context": inputs["task_context"], "consumed_inputs": inputs["consumed_inputs"]}) + key = ["control", self.execution.execution_id()] + saved = self.execution.store.journal_read("decision", key) + if saved: + if saved["payload"]["input_digest"] != digest: + self.execution._pause(node["id"], digest) + choice = saved["payload"]["decision"] + else: + choice = choose() + self.execution.store.journal_commit( + self.execution.lease.token, "decision", key, { + "execution_id": self.execution.execution_id(), "node_id": node["id"], "attempt": 1, + "iteration_path": [], "input_digest": digest, "decision": choice, + "consumed_inputs": inputs["consumed_inputs"], + }, updates={"cursor": {"region_id": self.execution.region_id, "node_id": node["id"]}}, + admission=True, immutable=True, + ) + return choice + + def _skip(self, node, region_id, reason): + self.execution.set_node(node, region_id) + self._admit_control(node) + decision = self.execution.store.journal_read("decision", ["control", self.execution.execution_id()]) + receipts = self._receipts([*((decision or {}).get("payload", {}).get("consumed_inputs") or []), *self.control_receipts]) + self.execution.finish_node(state="skipped", attempt=0, reason_code=reason, consumed_inputs=receipts) + self.completed[node["id"]] = {"state": "skipped"} + if node["kind"] == "if": + for branch in ("then", "else"): + for child in node[branch]["nodes"]: + self._skip(child, node[branch]["id"], reason) + join = self.compiled["nodes"][node["join"]["id"]]["node"] + self._skip(join, region_id, reason) + + def _join(self, node, region_id, branch, decision_summary): + join = self.compiled["nodes"][node["join"]["id"]]["node"] + self.execution.set_node(join, region_id) + receipts = [{ + "producer": decision_summary["producer"], "result_ref": decision_summary["result_ref"], + "output_name": decision_summary["authoritative_output"], + "output_ref": decision_summary["outputs"][decision_summary["authoritative_output"]]["result_ref"], + }] + exports, structured = {}, True + for export in node["join"]["exports"]: + source = export[branch] + binding = { + "name": export["name"], "source": source, "expected_kind": export["expected_kind"], + "required": export["required"], "allow_partial": True, + } + resolved = self.resolve([binding]) + if not resolved["bound_inputs"]: + continue + selected = resolved["bound_inputs"][0] + receipts.append(selected) + producer = self.completed[source["node_id"]] + structured = structured and producer.get("structured_validated", False) + descriptor = producer["summary"]["outputs"][selected["output_name"]] + exports[export["name"]] = { + "kind": descriptor["kind"], "result_ref": selected["output_ref"], + "selected_producer": selected, + } + self._admit_control(join) + receipts = self._receipts([*receipts, *self.control_receipts]) + summary = self._control_result(join, {"choice": branch}, receipts) + # Export descriptors point to the selected producer, never concatenated/reconstructed values. + identity = summary["producer"] + from functions_workflow_result_store import load_workflow_node_result, save_workflow_node_result + from functions_workflow_node_results import result_selectors + + manifest = load_workflow_node_result( + self.workflow, self.run_id, None, summary["result_ref"], **result_selectors(identity), + ) + manifest["outputs"] = exports + manifest["authoritative_output"] = next(iter(exports), None) + reference = save_workflow_node_result( + self.workflow, self.run_id, None, manifest, settings=self.settings, **result_selectors(identity), + ) + summary = workflow_result_summary(manifest, reference) + self.completed[join["id"]] = {"state": "completed", "summary": summary, "structured_validated": structured} + self.execution.finish_node(state="completed", attempt=1, decision={"choice": branch}, workflow_result=summary, + consumed_inputs=receipts) + + def _region(self, region): + children = region["nodes"] + index = 0 + while index < len(children): + node = children[index] + self.execution.set_node(node, region["id"]) + self.execution.check() + if node["kind"] == "task": + task = deepcopy(self.catalogue[node["task_id"]]) + if "run_when" in node: + inputs = self.resolve(task["inputs"], condition=self._predicate_names(node["run_when"])) + choice = self._decision(node, inputs, lambda: { + "choice": "run" if evaluate_predicate(node["run_when"], inputs["values"]) else "skip", + }) + control_summary = self._control_result(node, choice, inputs["consumed_inputs"]) + self.control_receipts.append(self._control_receipt(control_summary)) + if choice["choice"] == "skip": + self._skip(node, region["id"], "run_when_false") + index += 1 + continue + yield task + checkpoint = next((item for item in reversed(self.task_results) if item["task"]["id"] == task["id"]), None) + if checkpoint is None: + raise WorkflowInputError("A selected task did not save an execution outcome.") + result = checkpoint.get("result") or {} + summary = result.get("workflow_result") + state = checkpoint["status"] + if summary: + self.completed[node["id"]] = { + "state": state, "summary": summary, + "structured_validated": bool((task.get("output_contract") or {}).get("schema")), + } + self.execution.finish_node( + state=state, workflow_result=summary, workflow_validation=result.get("workflow_validation"), + consumed_inputs=checkpoint.get("consumed_inputs") or [], + ) + self.partial |= (result.get("workflow_validation") or {}).get("status") == "accepted_partial" + else: + self.completed[node["id"]] = {"state": "failed"} + self.execution.finish_node(state="failed", reason_code="task_failed") + self.failed |= state not in {"succeeded", "completed"} + else: + inputs = self.resolve(node["inputs"], condition=self._predicate_names(node["condition"])) + choice = self._decision(node, inputs, lambda: ( + {"choice": "then" if evaluate_predicate(node["condition"], inputs["values"]) else "else"} + if node["kind"] == "if" else + {"target": node["target"] if evaluate_predicate(node["condition"], inputs["values"]) else None} + )) + summary = self._control_result(node, choice, inputs["consumed_inputs"]) + self.control_receipts.append(self._control_receipt(summary)) + self.completed[node["id"]] = {"state": "completed", "summary": summary, "structured_validated": True} + self.execution.finish_node(state="completed", attempt=1, decision=choice, workflow_result=summary, + consumed_inputs=inputs["consumed_inputs"]) + if node["kind"] == "if": + branch = choice["choice"] + other = "else" if branch == "then" else "then" + for child in node[other]["nodes"]: + self._skip(child, node[other]["id"], "unselected_branch") + yield from self._region(node[branch]) + self._join(node, region["id"], branch, summary) + elif choice.get("target"): + target = choice["target"] + destination = next( + (position for position, child in enumerate(children) if child["id"] == target.get("node_id")), + len(children), + ) + for child in children[index + 1:destination]: + self._skip(child, region["id"], "forward_route") + index = destination + continue + index += 1 + + def tasks(self): + yield from self._region(self.compiled["flow"]) + self.final_outputs = self.resolve(self.compiled["flow"]["outputs"])["bound_inputs"] + self.execution._update(self.execution.check(), {"progress": { + "completed": len(self.completed), "total": len(self.compiled["nodes"]), + }}) + self.finished = True diff --git a/application/single_app/functions_workflow_identity.py b/application/single_app/functions_workflow_identity.py new file mode 100644 index 000000000..a33d42dd9 --- /dev/null +++ b/application/single_app/functions_workflow_identity.py @@ -0,0 +1,60 @@ +# functions_workflow_identity.py +"""Stable authorized node/execution identity, independent of a mutable attempt.""" + +import hashlib +import json + +from functions_workflow_definitions import workflow_definition_revision + + +def canonical_digest(value): + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False) + return hashlib.sha256(encoded.encode("ascii")).hexdigest() + + +def workflow_execution_id(workflow, run_id, node_id, iteration_path=None): + path = [] if iteration_path is None else iteration_path + if not isinstance(path, list) or path: + raise ValueError("M4A executions require an empty iteration path.") + if not all(isinstance(value, str) and value for value in (workflow.get("id"), workflow.get("user_id"), run_id, node_id)): + raise ValueError("An execution requires its authorized workflow, run and node.") + return canonical_digest({ + "scope_type": "group" if workflow.get("group_id") else "personal", + "scope_id": workflow.get("group_id") or workflow["user_id"], + "workflow_id": workflow["id"], "run_id": run_id, + "definition_revision": workflow_definition_revision(workflow), + "node_id": node_id, "iteration_path": path, + }) + + +def workflow_node_identity(workflow, run_id, node_id, execution_id, attempt, *, task_id=None, iteration_path=None): + path = [] if iteration_path is None else iteration_path + if execution_id != workflow_execution_id(workflow, run_id, node_id, path) or type(attempt) is not int or attempt < 1: + raise ValueError("The execution or attempt does not match its authorized producer.") + if workflow.get("definition_version") != 3: + raise ValueError("Exact node identities require a structured workflow.") + matched = node_id == workflow.get("flow", {}).get("id") and task_id is None + pending = list(workflow.get("flow", {}).get("nodes") or []) + checked = 0 + while pending: + node = pending.pop() + checked += 1 + if checked > 256: + raise ValueError("The saved flow identity is not bounded.") + if node.get("id") == node_id: + matched = node.get("task_id") == task_id + if node.get("kind") == "if": + matched |= node.get("join", {}).get("id") == node_id and task_id is None + pending.extend(node.get("then", {}).get("nodes") or []) + pending.extend(node.get("else", {}).get("nodes") or []) + if not matched: + raise ValueError("The node and task identity do not match the saved flow.") + identity = { + "workflow_id": workflow["id"], "run_id": run_id, "node_id": node_id, + "execution_id": execution_id, "iteration_path": list(path), "attempt": attempt, + } + if task_id is not None: + if not isinstance(task_id, str) or not task_id: + raise ValueError("A task producer requires a real task id.") + identity["task_id"] = task_id + return identity diff --git a/application/single_app/functions_workflow_journal.py b/application/single_app/functions_workflow_journal.py new file mode 100644 index 000000000..741899412 --- /dev/null +++ b/application/single_app/functions_workflow_journal.py @@ -0,0 +1,336 @@ +# functions_workflow_journal.py +"""Paged schema-2 journal records in the existing fenced workflow partition.""" + +import base64 +import json +from copy import deepcopy + +from azure.cosmos import exceptions as cosmos_exceptions + +from functions_workflow_identity import canonical_digest, workflow_execution_id + + +JOURNAL_TYPE = "workflow_runtime_journal" +JOURNAL_KINDS = frozenset({"execution", "attempt", "unit", "decision", "request", "admission"}) +PUBLIC_EXECUTION_FIELDS = ( + "execution_id", "node_id", "node_kind", "task_id", "iteration_path", "region_id", + "sequence", "state", "attempt", "reason_code", "decision", "workflow_result", + "workflow_validation", "consumed_inputs", "started_at", "completed_at", +) +PUBLIC_DECISION_FIELDS = ( + "sequence", "execution_id", "node_id", "iteration_path", "attempt", "gate_id", + "gate_kind", "choice", "decision", "actor_user_id", "decided_at", "reason_code", "input_digest", +) + + +def journal_record_id(kind, key): + return f"workflow-journal:v2:{kind}:{canonical_digest(key)}" + + +class WorkflowJournalMixin: + """Atomic decision/cursor/admission updates; control does not grow per execution.""" + + def journal_read(self, kind, key): + self._read_control() + if kind not in JOURNAL_KINDS: + raise ValueError("Unsupported workflow journal record kind.") + try: + row = self.container.read_item( + item=journal_record_id(kind, key), partition_key=self.identity["run_id"], + ) + except cosmos_exceptions.CosmosResourceNotFoundError: + return None + if ( + any(row.get(name) != value for name, value in self.identity.items()) + or row.get("type") != JOURNAL_TYPE or row.get("record_kind") != kind or row.get("key") != key + ): + self._journal_conflict("identity_mismatch") + return row + + @staticmethod + def _journal_conflict(code): + # Runtime store imports this mixin; resolve its public error only at use. + from functions_workflow_runtime_store import WorkflowRuntimeConflict + + raise WorkflowRuntimeConflict(code) + + def journal_commit(self, token, kind, key, payload, *, updates=None, admission=False, immutable=False): + if kind not in JOURNAL_KINDS: + raise ValueError("Unsupported workflow journal record kind.") + identifier = journal_record_id(kind, key) + for _ in range(8): + control = self._read_control() + self._assert_current_owned(control, token) + if control.get("schema_version") != 2: + self._journal_conflict("schema_mismatch") + previous = self.journal_read(kind, key) + if previous is not None and immutable: + if previous.get("payload") != payload: + self._journal_conflict("immutable_conflict") + return previous + replacement = self._base_replacement(control) + if admission and previous is None: + admitted = int(control.get("admitted_count") or 0) + if admitted >= control["max_executions"]: + from functions_workflow_execution import WorkflowSuspended + + self.pause_execution_limit(token, "execution_budget_exceeded") + raise WorkflowSuspended("paused") + replacement["admitted_count"] = admitted + 1 + if self._now().isoformat() >= control["deadline_at"]: + from functions_workflow_execution import WorkflowSuspended + + self.pause_execution_limit(token, "deadline_exceeded") + raise WorkflowSuspended("paused") + sequence = previous["sequence"] if previous else int(control.get("journal_sequence") or 0) + 1 + if previous is None: + replacement["journal_sequence"] = sequence + counts = dict(control.get("journal_counts") or {}) + counts[kind] = int(counts.get(kind) or 0) + 1 + replacement["journal_counts"] = counts + if kind == "unit": + was_completed = bool(previous and previous["payload"].get("state") == "completed") + is_completed = payload.get("state") == "completed" + replacement["completed_unit_count"] = int(control.get("completed_unit_count") or 0) + int(is_completed) - int(was_completed) + if updates: + if set(updates) - {"cursor", "progress", "phase", "state", "gate", "lease"}: + self._journal_conflict("invalid_payload") + replacement.update(deepcopy(updates)) + replacement["version"] = control["version"] + 1 + body = self._runtime_record({ + "id": identifier, "run_id": self.identity["run_id"], "type": JOURNAL_TYPE, + "item_type": JOURNAL_TYPE, "record_kind": kind, "key": key, "sequence": sequence, + "execution_id": payload.get("execution_id"), "payload": deepcopy(payload), + }) + operation = ( + ("replace", (identifier, body), {"if_match_etag": previous["_etag"]}) + if previous else ("create", (body,)) + ) + try: + self.container.execute_item_batch(batch_operations=[ + ("replace", (control["id"], replacement), {"if_match_etag": control["_etag"]}), + operation, + ], partition_key=self.identity["run_id"]) + return self.journal_read(kind, key) + except (cosmos_exceptions.CosmosBatchOperationError, cosmos_exceptions.CosmosHttpResponseError) as exc: + if getattr(exc, "status_code", None) in {409, 412}: + continue + # A lost acknowledgement is reconciled against the exact immutable key/payload. + saved = self.journal_read(kind, key) + if saved and saved.get("payload") == payload: + self.assert_owned(token) + return saved + raise + self._journal_conflict("etag_conflict") + + def journal_page(self, kind, *, cursor=None, limit=50, execution_id=None): + control = self._read_control() + if kind not in {"execution", "attempt", "decision"} or type(limit) is not int or not 1 <= limit <= 100: + raise ValueError("Journal pages require a supported kind and a limit from 1 to 100.") + scope = canonical_digest({**self.identity, "kind": kind, "execution_id": execution_id}) + after = 0 + through = int(control.get("journal_sequence") or 0) + total = int((control.get("journal_counts") or {}).get(kind) or 0) + if cursor: + try: + if not isinstance(cursor, str) or len(cursor) > 1024: + raise ValueError + decoded = json.loads(base64.urlsafe_b64decode(cursor.encode("ascii"))) + if set(decoded) != {"scope", "after", "through", "total"} or decoded["scope"] != scope: + raise ValueError + after, bound, count = decoded["after"], decoded["through"], decoded["total"] + if not all(type(value) is int for value in (after, bound, count)) or not ( + 0 <= after <= bound <= through and 0 <= count <= total + ): + raise ValueError + through, total = bound, count + except (ValueError, TypeError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError("Invalid workflow journal cursor.") from exc + parameters = [ + {"name": "@run_id", "value": self.identity["run_id"]}, + {"name": "@workflow_id", "value": self.identity["workflow_id"]}, + {"name": "@kind", "value": kind}, {"name": "@after", "value": after}, + {"name": "@through", "value": through}, + ] + where = ( + "c.run_id = @run_id AND c.workflow_id = @workflow_id " + "AND c.item_type = 'workflow_runtime_journal' AND c.record_kind = @kind " + "AND c.sequence > @after AND c.sequence <= @through" + ) + if execution_id is not None: + where += " AND c.execution_id = @execution_id" + parameters.append({"name": "@execution_id", "value": execution_id}) + rows = list(self.container.query_items( + query=f"SELECT TOP {limit + 1} * FROM c WHERE {where} ORDER BY c.sequence ASC", + parameters=parameters, partition_key=self.identity["run_id"], + )) + for row in rows: + if any(row.get(name) != value for name, value in self.identity.items()) or row.get("record_kind") != kind: + self._journal_conflict("identity_mismatch") + fields = PUBLIC_DECISION_FIELDS if kind == "decision" else PUBLIC_EXECUTION_FIELDS + entries = [] + for row in rows[:limit]: + entry = {name: deepcopy(value) for name, value in {**row["payload"], "sequence": row["sequence"]}.items() if name in fields} + decision = entry.get("decision") + if isinstance(decision, dict): + decision = {name: value for name, value in decision.items() if name in {"choice", "target"}} + target = decision.get("target") + if isinstance(target, dict): + decision["target"] = {name: value for name, value in target.items() if name in {"node_id", "exit_region_id"}} + entry["decision"] = decision + if kind == "decision": + entry["choice"] = decision.get("choice") or ("route" if target else "continue") + if decision.get("choice") in {"then", "else"}: + entry["selected_branch"] = decision["choice"] + if isinstance(target, dict): + entry["target_node_id"] = target.get("node_id") + entry["exit_region_id"] = target.get("exit_region_id") + entries.append(entry) + next_cursor = None + if len(rows) > limit: + next_cursor = base64.urlsafe_b64encode(json.dumps( + {"scope": scope, "after": rows[limit - 1]["sequence"], "through": through, "total": total}, separators=(",", ":"), + ).encode("ascii")).decode("ascii") + return {"items": entries, "next_cursor": next_cursor, + "total_count": total} + + def journal_decide(self, *, expected_version, gate_id, choice, actor_user_id, request_id): + from functions_workflow_runtime_store import NEXT_STATE_BY_DECISION, _require_id + + if type(expected_version) is not int: + self._journal_conflict("stale_version") + for name, value in (("gate_id", gate_id), ("choice", choice), + ("actor_user_id", actor_user_id), ("request_id", request_id)): + _require_id(value, name) + request_key = ["decision", request_id] + wanted = {"gate_id": gate_id, "choice": choice, "actor_user_id": actor_user_id} + for _ in range(8): + control = self._read_control() + prior = self.journal_read("request", request_key) + if prior: + if prior["payload"] != wanted: + self._journal_conflict("request_conflict") + return control + if control["version"] != expected_version: + self._journal_conflict("stale_version") + gate = control.get("gate") or {} + if gate.get("id") != gate_id: + self._journal_conflict("stale_gate") + pair = (gate.get("kind"), choice) + if pair not in NEXT_STATE_BY_DECISION or choice not in gate.get("choices", []): + self._journal_conflict("invalid_choice") + if choice in {"approve", "retry", "resume"} and self._now().isoformat() >= control["deadline_at"]: + self.expire_deadline() + self._journal_conflict("deadline_exceeded") + decision = { + **wanted, "gate_kind": gate["kind"], "decided_at": self._now().isoformat(), + **{key: deepcopy(gate[key]) for key in ( + "unit_id", "execution_id", "node_id", "iteration_path", "attempt", + "input_digest", "definition_revision", + ) if key in gate}, + } + active = self.journal_read("execution", gate.get("execution_id")) if gate.get("execution_id") else None + if active: + decision["consumed_inputs"] = deepcopy(active["payload"].get("consumed_inputs") or []) + decision["reference_sources"] = deepcopy(active["payload"].get("reference_sources") or []) + replacement = self._base_replacement(control) + sequence = int(control.get("journal_sequence") or 0) + replacement.update(state=NEXT_STATE_BY_DECISION[pair], gate=None, lease=None, + version=control["version"] + 1, journal_sequence=sequence + 2) + counts = dict(control.get("journal_counts") or {}) + operations = [("replace", (control["id"], replacement), {"if_match_etag": control["_etag"]})] + for offset, (kind, key, payload) in enumerate(( + ("decision", ["gate", gate_id], decision), ("request", request_key, wanted), + ), start=1): + counts[kind] = int(counts.get(kind) or 0) + 1 + body = self._runtime_record({ + "id": journal_record_id(kind, key), "run_id": self.identity["run_id"], + "type": JOURNAL_TYPE, "item_type": JOURNAL_TYPE, "record_kind": kind, "key": key, + "sequence": sequence + offset, "execution_id": decision.get("execution_id"), "payload": payload, + }) + operations.append(("create", (body,))) + replacement["journal_counts"] = counts + try: + self.container.execute_item_batch(batch_operations=operations, partition_key=self.identity["run_id"]) + return self._read_control() + except (cosmos_exceptions.CosmosBatchOperationError, cosmos_exceptions.CosmosHttpResponseError) as exc: + if getattr(exc, "status_code", None) in {409, 412}: + continue + marker = self.journal_read("request", request_key) + if marker and marker["payload"] == wanted: + return self._read_control() + raise + self._journal_conflict("etag_conflict") + + def journal_request(self, action, *, actor_user_id, request_id, expected_version=None): + from functions_workflow_runtime_store import RESUMABLE_STATES, TERMINAL_STATES, _require_id + + _require_id(actor_user_id, "actor_user_id") + _require_id(request_id, "request_id") + key = [action, request_id] + wanted = {"action": action, "actor_user_id": actor_user_id} + for _ in range(8): + control = self._read_control() + marker = self.journal_read("request", key) + if marker: + if marker["payload"] != wanted: + self._journal_conflict("request_conflict") + return control + if action == "resume": + if type(expected_version) is not int or expected_version != control["version"]: + self._journal_conflict("stale_version") + if control["state"] not in RESUMABLE_STATES: + self._journal_conflict("invalid_state") + if self._now().isoformat() >= control["deadline_at"]: + self.expire_deadline() + self._journal_conflict("deadline_exceeded") + state = "queued" + elif action == "cancel": + if control["state"] in TERMINAL_STATES: + return control + state = "cancelled" + else: + self._journal_conflict("invalid_payload") + replacement = self._base_replacement(control) + sequence = int(control.get("journal_sequence") or 0) + 1 + counts = dict(control.get("journal_counts") or {}) + counts["request"] = int(counts.get("request") or 0) + 1 + replacement.update(state=state, gate=None, lease=None, version=control["version"] + 1, + journal_sequence=sequence, journal_counts=counts) + body = self._runtime_record({ + "id": journal_record_id("request", key), "run_id": self.identity["run_id"], + "type": JOURNAL_TYPE, "item_type": JOURNAL_TYPE, "record_kind": "request", + "key": key, "sequence": sequence, "payload": wanted, + }) + operations = [ + ("replace", (control["id"], replacement), {"if_match_etag": control["_etag"]}), + ("create", (body,)), + ] + if action == "cancel": + node_id = (control.get("cursor") or {}).get("node_id") + execution_id = (control.get("gate") or {}).get("execution_id") + if not execution_id and node_id: + execution_id = workflow_execution_id(self.workflow, self.identity["run_id"], node_id) + active = self.journal_read("execution", execution_id) if execution_id else None + if active and active["payload"].get("state") in {"running", "waiting_output", "waiting_approval", "waiting_recovery", "paused"}: + attempt = self.journal_read("attempt", [execution_id, active["payload"]["attempt"]]) + for row in (active, attempt): + if row is None or row["payload"].get("state") not in {"running", "waiting_output", "waiting_approval", "waiting_recovery", "paused"}: + continue + cancelled = {key: value for key, value in row.items() if not key.startswith("_")} + cancelled["payload"] = { + **cancelled["payload"], "state": "cancelled", "reason_code": "run_cancelled", + "completed_at": self._now().isoformat(), + } + operations.append(("replace", (row["id"], cancelled), {"if_match_etag": row["_etag"]})) + try: + self.container.execute_item_batch(batch_operations=operations, partition_key=self.identity["run_id"]) + return self._read_control() + except (cosmos_exceptions.CosmosBatchOperationError, cosmos_exceptions.CosmosHttpResponseError) as exc: + if getattr(exc, "status_code", None) in {409, 412}: + continue + marker = self.journal_read("request", key) + if marker and marker["payload"] == wanted: + return self._read_control() + raise + self._journal_conflict("etag_conflict") diff --git a/application/single_app/functions_workflow_node_results.py b/application/single_app/functions_workflow_node_results.py new file mode 100644 index 000000000..3afcafeac --- /dev/null +++ b/application/single_app/functions_workflow_node_results.py @@ -0,0 +1,177 @@ +# functions_workflow_node_results.py +"""Exact node result readers, including control provenance and paged lineage.""" + +import json +from collections.abc import Mapping + +from functions_analysis_access import AnalysisResultUnavailable, analysis_source_snapshot, authorize_analysis_sources +from functions_workflow_identity import canonical_digest, workflow_node_identity +from functions_workflow_result_store import load_workflow_node_result + + +def result_selectors(identity): + return {key: identity[key] for key in ("node_id", "execution_id", "iteration_path", "attempt")} + + +def load_node_result(workflow, run_id, identity, reference, *, load_result=load_workflow_node_result): + expected = workflow_node_identity( + workflow, run_id, identity.get("node_id"), identity.get("execution_id"), identity.get("attempt"), + task_id=identity.get("task_id"), iteration_path=identity.get("iteration_path"), + ) + if expected != identity: + raise AnalysisResultUnavailable("analysis_lineage_invalid") + return load_result(workflow, run_id, identity.get("task_id"), reference, **result_selectors(identity)) + + +def iter_consumed_inputs(manifest, load_section): + from functions_workflow_results import iter_result_records + + if "consumed_inputs_index" not in manifest: + values = manifest.get("consumed_inputs") or [] + if not isinstance(values, list): + raise AnalysisResultUnavailable("analysis_lineage_invalid") + else: + synthetic = {**manifest, "outputs": {"lineage": manifest["consumed_inputs_index"]}} + values = iter_result_records(synthetic, "lineage", load_section) + for item in values: + if not isinstance(item, Mapping): + raise AnalysisResultUnavailable("analysis_lineage_invalid") + yield item + + +def read_consumed_inputs(manifest, load_section): + return list(iter_consumed_inputs(manifest, load_section)) + + +def authorize_workflow_node_result_read( + workflow, run_id, identity, reference, *, reader_user_id=None, manifest=None, + load_result=load_workflow_node_result, source_resolver=None, +): + root = manifest if manifest is not None else load_node_result(workflow, run_id, identity, reference, load_result=load_result) + active, visited, sources = set(), set(), {} + loaded = {} + + def enter(producer, ref, current): + key = (canonical_digest(producer), canonical_digest(ref)) + if key in active or len(visited) + len(active) > 100000: + raise AnalysisResultUnavailable("analysis_lineage_invalid") + if key in visited: + return None + expected = workflow_node_identity( + workflow, run_id, producer.get("node_id"), producer.get("execution_id"), producer.get("attempt"), + task_id=producer.get("task_id"), iteration_path=producer.get("iteration_path"), + ) + if producer != expected or not isinstance(current, Mapping) or ( + current.get("contract_version") != "workflow-result-v2" or current.get("identity") != expected + ): + raise AnalysisResultUnavailable("analysis_lineage_invalid") + active.add(key) + access = current.get("analysis_access") + if access is not None: + if not isinstance(access, Mapping) or access.get("version") != "analysis-source-access-v1": + raise AnalysisResultUnavailable("analysis_lineage_invalid") + direct = analysis_source_snapshot(access.get("sources")) + if not direct: + raise AnalysisResultUnavailable("analysis_source_manifest_missing") + sources.update({canonical_digest(source): source for source in direct}) + selected = set() + for descriptor in (current.get("outputs") or {}).values(): + if not isinstance(descriptor, Mapping): + raise AnalysisResultUnavailable("analysis_lineage_invalid") + if descriptor.get("selected_producer"): + receipt = descriptor["selected_producer"] + if not isinstance(receipt, Mapping) or receipt.get("output_ref") != descriptor.get("result_ref"): + raise AnalysisResultUnavailable("analysis_lineage_invalid") + selected.add(canonical_digest(receipt)) + consumed = iter_consumed_inputs( + current, lambda section: load_node_result(workflow, run_id, producer, section, load_result=load_result), + ) + return key, iter(consumed), selected + + pending = [enter(identity, reference, root)] + while pending: + key, children, selected = pending[-1] + item = next(children, None) + if item is None: + if selected: + raise AnalysisResultUnavailable("analysis_lineage_invalid") + active.remove(key) + visited.add(key) + pending.pop() + continue + selected.discard(canonical_digest(item)) + parent_identity, parent_ref = item.get("producer"), item.get("result_ref") + if not isinstance(parent_identity, dict) or not isinstance(parent_ref, dict): + raise AnalysisResultUnavailable("analysis_lineage_invalid") + parent_key = (canonical_digest(parent_identity), canonical_digest(parent_ref)) + parent = loaded.get(parent_key) + if parent is None: + parent = load_node_result(workflow, run_id, parent_identity, parent_ref, load_result=load_result) + loaded[parent_key] = parent + output = (parent.get("outputs") or {}).get(item.get("output_name")) or {} + if output.get("result_ref") != item.get("output_ref"): + raise AnalysisResultUnavailable("analysis_lineage_invalid") + child = enter(parent_identity, parent_ref, parent) + if child is not None: + pending.append(child) + snapshots = analysis_source_snapshot(list(sources.values())) + access = authorize_analysis_sources( + reader_user_id or workflow["user_id"], snapshots, resolver=source_resolver, + ) if snapshots else {"source_count": 0, "source_snapshot_changed": False} + return root, {**access, "sources": snapshots} + + +def load_workflow_node_input( + workflow, run_id, identity, reference, *, output_name="authoritative", allow_partial=False, + reader_user_id=None, load_result=load_workflow_node_result, source_resolver=None, required=True, +): + from functions_workflow_results import _require_completed_result, read_result_records + + manifest, access = authorize_workflow_node_result_read( + workflow, run_id, identity, reference, reader_user_id=reader_user_id, + load_result=load_result, source_resolver=source_resolver, + ) + _require_completed_result(manifest, allow_partial=allow_partial) + if access["source_snapshot_changed"]: + raise AnalysisResultUnavailable("analysis_source_snapshot_changed") + validation = manifest.get("workflow_validation") or {} + if validation.get("eligible") is not True: + raise ValueError("The producer's output contract is not eligible.") + if validation.get("status") == "accepted_partial" and not allow_partial: + raise ValueError("This input does not accept partial results.") + name = manifest.get("authoritative_output") if output_name == "authoritative" else output_name + descriptor = (manifest.get("outputs") or {}).get(name) + if not isinstance(descriptor, dict): + if required is False: + return None, None + raise ValueError("The exact selected output is unavailable.") + receipt = {"producer": identity, "output_name": name, "result_ref": reference, + "output_ref": descriptor["result_ref"]} + if access["source_count"]: + receipt["analysis_result"] = True + loader = lambda ref: load_node_result(workflow, run_id, identity, ref, load_result=load_result) + if descriptor.get("selected_producer"): + selected = descriptor["selected_producer"] + payload, _ = load_workflow_node_input( + workflow, run_id, selected["producer"], selected["result_ref"], + output_name=selected["output_name"], allow_partial=allow_partial, + reader_user_id=reader_user_id, load_result=load_result, source_resolver=source_resolver, + ) + return payload, receipt + if descriptor.get("storage_kind") == "record_pages": + value, _ = read_result_records(manifest, name, loader) + output = {"producer": identity, "contract_version": "workflow-result-v2", + "output_name": name, "kind": descriptor["kind"], "value": value} + else: + output = loader(descriptor["result_ref"]) + if ( + output.get("producer") != identity or output.get("contract_version") != "workflow-result-v2" + or output.get("output_name") != name or output.get("kind") != descriptor.get("kind") + ): + raise ValueError("Saved output section does not match its exact manifest.") + return json.dumps({ + "consumed_result": receipt, "provenance": manifest.get("provenance") or {}, + "coverage": manifest.get("coverage") or {}, "validation": manifest.get("validation") or {}, + "kind": output["kind"], "value": output["value"], + "source_snapshot_changed": access["source_snapshot_changed"], + }, ensure_ascii=False, allow_nan=False, sort_keys=True), receipt diff --git a/application/single_app/functions_workflow_result_store.py b/application/single_app/functions_workflow_result_store.py index 24015388d..7436d6507 100644 --- a/application/single_app/functions_workflow_result_store.py +++ b/application/single_app/functions_workflow_result_store.py @@ -44,6 +44,7 @@ from azure.storage.blob import ContentSettings from functions_workflow_runtime_store import WorkflowRuntimeConflict +from functions_workflow_identity import workflow_execution_id, workflow_node_identity STORAGE_SCHEMA_VERSION = 1 RESULT_RECORD_TYPE = "workflow_result_chunk" @@ -109,10 +110,24 @@ def _scope(workflow, run_id): return {**_workflow_scope(workflow), "run_id": _identifier(run_id)} -def _identity(workflow, run_id, task_id): +def _identity(workflow, run_id, task_id, *, execution_id=None, attempt=None, node_id=None, iteration_path=None): + if any(value is not None for value in (execution_id, attempt, node_id, iteration_path)): + identity = workflow_node_identity( + workflow, run_id, node_id, execution_id, attempt, task_id=task_id, iteration_path=iteration_path, + ) + # Compact transport bindings remain hashable; the manifest retains the verified path. + return {**_scope(workflow, run_id), **{key: value for key, value in identity.items() if key != "iteration_path"}} return {**_scope(workflow, run_id), "task_id": _identifier(task_id)} +def read_workflow_node_result_page(workflow, run_id, task_id, reference, *, node_id, execution_id, attempt, + iteration_path=None, offset=0, limit=2000): + return _configured_store(workflow).read_page( + workflow, run_id, task_id, reference, node_id=node_id, execution_id=execution_id, + attempt=attempt, iteration_path=[] if iteration_path is None else iteration_path, offset=offset, limit=limit, + ) + + def _chat_scope(user_id, conversation_id, message_id=None): scope = { "scope_type": "chat", @@ -211,7 +226,8 @@ def _blob_name(identity, reference): return f"{_chat_blob_prefix(identity)}{reference['sha256']}.json" if identity["scope_type"] == "orchestration": return f"{_orchestration_blob_prefix(identity)}{_hash_identifier(identity['step_id'])}/{reference['sha256']}.json" - return f"{_run_blob_prefix(identity)}{_hash_identifier(identity['task_id'])}/{reference['sha256']}.json" + key = f"{identity['execution_id']}:{identity['attempt']}" if identity.get("execution_id") else identity["task_id"] + return f"{_run_blob_prefix(identity)}{_hash_identifier(key)}/{reference['sha256']}.json" def _conversation_blob_scope_metadata(scope): @@ -248,7 +264,10 @@ def _blob_metadata(identity, reference): "scope_hash": _hash_identifier(identity["scope_id"]), "workflow_hash": _hash_identifier(identity["workflow_id"]), "run_hash": _hash_identifier(identity["run_id"]), - "task_hash": _hash_identifier(identity["task_id"]), + **({"execution_hash": identity["execution_id"], "attempt": str(identity["attempt"]), + "execution_storage_hash": _hash_identifier(f"{identity['execution_id']}:{identity['attempt']}"), + "node_hash": _hash_identifier(identity["node_id"])} if identity.get("execution_id") + else {"task_hash": _hash_identifier(identity["task_id"])}), "sha256": reference["sha256"], "size_bytes": str(reference["size_bytes"]), "media_type": RESULT_MEDIA_TYPE, @@ -306,7 +325,10 @@ def _workflow_execution_guard(identity, execution=None): execution = execution or _active_workflow_execution() if execution is None or "workflow_id" not in identity: return None - if execution.workflow["id"] != identity["workflow_id"] or execution.run_id != identity["run_id"]: + if ( + execution.workflow["id"] != identity["workflow_id"] or execution.run_id != identity["run_id"] + or any(identity.get(key) != value for key, value in _workflow_scope(execution.workflow).items()) + ): raise WorkflowResultIntegrityError("Workflow result write does not match the active execution.") execution.check() return execution @@ -406,9 +428,9 @@ def _blob_chunks(self, identity, reference): etag=properties.etag, match_condition=MatchConditions.IfNotModified, validate_content=True, ).chunks() - def save(self, workflow, run_id, task_id, result): + def save(self, workflow, run_id, task_id, result, **selectors): """Persist an envelope without overwriting another immutable result.""" - return self._save(_identity(workflow, run_id, task_id), result) + return self._save(_identity(workflow, run_id, task_id, **selectors), result) def save_chat( self, user_id, conversation_id, message_id, result, *, guard_token=None, require_analysis_guard=False, @@ -871,7 +893,7 @@ def _analysis_scope_rows(self, scope): identity_fields = ( "c.user_id, c.conversation_id, c.message_id" if scope["scope_type"] == "chat" else "c.user_id, c.conversation_id, c.step_id" if scope["scope_type"] == "orchestration" - else "c.workflow_id, c.task_id" + else "c.workflow_id, c.task_id, c.execution_id, c.node_id, c.attempt" ) return self.container.query_items( query=( @@ -900,6 +922,8 @@ def _analysis_row_identity(self, scope, row): ) else: identity = {**scope, "task_id": _identifier(row.get("task_id"))} + if row.get("execution_id"): + identity.update(execution_id=row["execution_id"], node_id=row["node_id"], attempt=row["attempt"]) _require_fields(row, identity) return identity @@ -947,9 +971,9 @@ def _read_chunk(self, identity, reference, manifest, index): raise WorkflowResultIntegrityError("Stored workflow result chunk size or digest does not match.") return payload - def load(self, workflow, run_id, task_id, reference): + def load(self, workflow, run_id, task_id, reference, **selectors): """Read the full envelope, verifying metadata, reconstructed size, and SHA-256.""" - return self._load(_identity(workflow, run_id, task_id), reference) + return self._load(_identity(workflow, run_id, task_id, **selectors), reference) def load_chat(self, user_id, conversation_id, message_id, reference): """Load and verify the complete result for an authorized chat binding.""" @@ -982,9 +1006,9 @@ def _load(self, identity, reference): raise WorkflowResultIntegrityError("Stored workflow result is not a JSON object.") return result - def read_page(self, workflow, run_id, task_id, reference, *, offset=0, limit=DEFAULT_PAGE_BYTES): + def read_page(self, workflow, run_id, task_id, reference, *, offset=0, limit=DEFAULT_PAGE_BYTES, **selectors): """Read bounded ASCII JSON bytes; complete means EOF, not a complete envelope.""" - return self._read_page(_identity(workflow, run_id, task_id), reference, offset=offset, limit=limit) + return self._read_page(_identity(workflow, run_id, task_id, **selectors), reference, offset=offset, limit=limit) def read_chat_page(self, user_id, conversation_id, message_id, reference, *, offset=0, limit=DEFAULT_PAGE_BYTES): """Read bounded serialized bytes, not semantic records or model-ready content.""" @@ -1096,10 +1120,21 @@ def _delete_scoped_blobs(self, scope): "scope_hash": _hash_identifier(scope["scope_id"]), "workflow_hash": _hash_identifier(scope["workflow_id"]), "run_hash": _hash_identifier(scope["run_id"]), - "task_hash": object_hash, "sha256": digest_filename[:-5], "media_type": RESULT_MEDIA_TYPE, } + metadata = properties.metadata or {} + if metadata.get("execution_hash"): + execution_hash, attempt = metadata.get("execution_hash"), metadata.get("attempt") + if ( + not isinstance(execution_hash, str) or not _DIGEST_PATTERN.fullmatch(execution_hash) + or not isinstance(attempt, str) or not re.fullmatch(r"[1-9][0-9]{0,11}", attempt) + or _hash_identifier(f"{execution_hash}:{attempt}") != object_hash + ): + raise WorkflowResultIntegrityError("The execution Blob identity is invalid.") + expected["execution_storage_hash"] = object_hash + else: + expected["task_hash"] = object_hash _require_fields(properties.metadata, expected) blob = self.blob_client.get_blob_client(container=self.blob_container_name, blob=name) try: @@ -1123,7 +1158,7 @@ def delete_run_results(self, workflow, run_id): self._delete_scoped_blobs(scope) records = self.container.query_items( query=( - "SELECT c.id, c.run_id, c.workflow_id, c.scope_type, c.scope_id, c.task_id, " + "SELECT c.id, c.run_id, c.workflow_id, c.scope_type, c.scope_id, c.task_id, c.node_id, c.execution_id, c.attempt, " "c.type, c.item_type, c.storage, c.schema_version, c.sha256, c.size_bytes, " "c.chunk_count, c.record_kind, c.chunk_index FROM c " "WHERE c.run_id = @run_id AND c.workflow_id = @workflow_id " @@ -1139,6 +1174,22 @@ def delete_run_results(self, workflow, run_id): ) self._delete_records(scope, records) self._delete_analysis_controls(scope) + journal_rows = self.container.query_items( + query=( + "SELECT c.id, c.workflow_id, c.run_id, c.scope_type, c.scope_id FROM c " + "WHERE c.run_id = @run_id AND c.workflow_id = @workflow_id AND c.scope_type = @scope_type " + "AND c.scope_id = @scope_id AND c.type = @record_type AND c.item_type = @record_type" + ), + parameters=[*({"name": f"@{key}", "value": value} for key, value in scope.items()), + {"name": "@record_type", "value": "workflow_runtime_journal"}], + partition_key=run_id, max_item_count=100, + ) + for row in journal_rows: + _require_fields(row, scope) + try: + self.container.delete_item(item=row["id"], partition_key=run_id) + except CosmosResourceNotFoundError: + pass def delete_chat_results(self, user_id, conversation_id, message_id=None): """Delete every private section/revision for a real message or conversation. @@ -1197,7 +1248,14 @@ def _delete_records(self, scope, records): ) _require_fields(record, identity) else: - identity = {**scope, "task_id": _identifier(record.get("task_id"))} + if record.get("execution_id"): + identity = { + **scope, "execution_id": _identifier(record.get("execution_id")), + "node_id": _identifier(record.get("node_id")), "attempt": _positive_integer(record.get("attempt"), "Attempt"), + **({"task_id": _identifier(record["task_id"])} if record.get("task_id") else {}), + } + else: + identity = {**scope, "task_id": _identifier(record.get("task_id"))} reference = _validate_reference({key: record.get(key) for key in REFERENCE_FIELDS}) kind = record.get("record_kind") if kind == "manifest": @@ -1259,6 +1317,41 @@ def load_workflow_task_result(workflow, run_id, task_id, reference): return _configured_store(workflow).load(workflow, run_id, task_id, reference) +def save_workflow_node_result(workflow, run_id, task_id, result, *, node_id, execution_id, attempt, + iteration_path=None, settings=None): + return _configured_store(workflow, settings=settings, for_write=True).save( + workflow, run_id, task_id, result, node_id=node_id, execution_id=execution_id, + attempt=attempt, iteration_path=[] if iteration_path is None else iteration_path, + ) + + +def load_workflow_node_result(workflow, run_id, task_id, reference, *, node_id, execution_id, attempt, + iteration_path=None): + return _configured_store(workflow).load( + workflow, run_id, task_id, reference, node_id=node_id, execution_id=execution_id, + attempt=attempt, iteration_path=[] if iteration_path is None else iteration_path, + ) + + +def save_workflow_runtime_result(workflow, run_id, result, *, settings=None): + node_id = workflow["flow"]["id"] + return save_workflow_node_result( + workflow, run_id, None, result, settings=settings, node_id=node_id, + execution_id=workflow_execution_id(workflow, run_id, node_id), attempt=1, iteration_path=[], + ) + + +def load_workflow_runtime_result(workflow, run_id, control, reference): + """Load using a verified control's frozen identity, before its definition can be read.""" + if any(control.get(key) != value for key, value in _scope(workflow, run_id).items()): + raise WorkflowResultIntegrityError("The runtime snapshot scope is invalid.") + selectors = control.get("snapshot_identity") or {} + if set(selectors) != {"node_id", "execution_id", "attempt", "iteration_path"} or selectors["iteration_path"] != []: + raise WorkflowResultIntegrityError("The runtime snapshot identity is invalid.") + identity = {**_scope(workflow, run_id), **{key: value for key, value in selectors.items() if key != "iteration_path"}} + return _configured_store(workflow)._load(identity, reference) + + def read_workflow_task_result_page(workflow, run_id, task_id, reference, *, offset=0, limit=65536): """Read only the bounded serialized byte range of an authorized task result.""" return _configured_store(workflow).read_page(workflow, run_id, task_id, reference, offset=offset, limit=limit) diff --git a/application/single_app/functions_workflow_results.py b/application/single_app/functions_workflow_results.py index 64104cdce..f42e9203c 100644 --- a/application/single_app/functions_workflow_results.py +++ b/application/single_app/functions_workflow_results.py @@ -14,6 +14,8 @@ DEFAULT_MAX_RESULT_SIZE_MB, _quota_bytes, load_workflow_task_result, + load_workflow_node_result, + save_workflow_node_result, save_workflow_task_result, ) @@ -159,6 +161,18 @@ def _provenance_references(values): def build_workflow_task_result(result, *, workflow, run_id, task, attempt_count=1): """Capture complete produced data without changing the existing chat reply.""" + if workflow.get("definition_version") == 3: + from functions_workflow_execution import current_workflow_execution + from functions_workflow_identity import workflow_node_identity + + execution = current_workflow_execution() + if execution is None or execution.node.get("task_id") != task.get("id"): + raise ValueError("A v3 task result requires its admitted execution.") + selectors = execution.selectors(attempt=attempt_count) + return _build_task_result(result, workflow_node_identity( + workflow, run_id, selectors["node_id"], selectors["execution_id"], attempt_count, + task_id=task["id"], iteration_path=[], + ), "workflow-result-v2") return _build_task_result( result, { @@ -336,9 +350,22 @@ def _build_task_result(result, identity, contract_version): def authorize_workflow_task_result_read( workflow, run_id, task_id, reference, *, reader_user_id=None, manifest=None, - load_result=load_workflow_task_result, source_resolver=None, + load_result=load_workflow_task_result, source_resolver=None, **selectors, ): """Recheck contributors of this result and every actually consumed ancestor.""" + if selectors: + from functions_workflow_identity import workflow_node_identity + from functions_workflow_node_results import authorize_workflow_node_result_read + + identity = workflow_node_identity( + workflow, run_id, selectors.get("node_id"), selectors.get("execution_id"), selectors.get("attempt"), + task_id=task_id, iteration_path=selectors.get("iteration_path"), + ) + return authorize_workflow_node_result_read( + workflow, run_id, identity, reference, reader_user_id=reader_user_id, manifest=manifest, + load_result=load_workflow_node_result if load_result is load_workflow_task_result else load_result, + source_resolver=source_resolver, + ) root = manifest if manifest is not None else load_result(workflow, run_id, task_id, reference) sources = [] active = set() @@ -426,10 +453,24 @@ def visit(current, current_task_id, current_ref): def authorize_workflow_run_read(workflow, run_id, *, reader_user_id=None, result_items=None, load_result=load_workflow_task_result, source_resolver=None): """Guard history/activity with every stored task result, without a UI item cap.""" + structured_run = False if result_items is None: # These are already scope-authorized workflow/run reads. Query only task # metadata directly so a failed store read cannot become an empty list. from config import cosmos_group_workflow_run_items_container, cosmos_personal_workflow_run_items_container + if workflow.get("definition_version") == 3: + from functions_workflow_runtime_store import WorkflowRuntimeConflict, workflow_runtime_store + + store = workflow_runtime_store(workflow, run_id) + try: + control = store.read() + except WorkflowRuntimeConflict as exc: + if exc.code != "not_found": + raise + control = None + if control and control.get("schema_version") == 2: + workflow = store.run_definition() + structured_run = True container = ( cosmos_group_workflow_run_items_container if workflow.get("group_id") @@ -448,10 +489,11 @@ def authorize_workflow_run_read(workflow, run_id, *, reader_user_id=None, result ) cache = {} - def cached_load(bound_workflow, bound_run_id, task_id, reference): - key = (bound_run_id, task_id, json.dumps(reference, sort_keys=True)) + def cached_load(bound_workflow, bound_run_id, task_id, reference, **selectors): + key = (bound_run_id, task_id, json.dumps(reference, sort_keys=True), json.dumps(selectors, sort_keys=True)) if key not in cache: - cache[key] = load_result(bound_workflow, bound_run_id, task_id, reference) + loader = load_workflow_node_result if selectors and load_result is load_workflow_task_result else load_result + cache[key] = loader(bound_workflow, bound_run_id, task_id, reference, **selectors) return cache[key] for item in result_items: @@ -465,11 +507,22 @@ def cached_load(bound_workflow, bound_run_id, task_id, reference): authorize_workflow_task_result_read( workflow, run_id, item.get("task_id"), reference, reader_user_id=reader_user_id, source_resolver=source_resolver, load_result=cached_load, + **({key: item['workflow_result']['producer'][key] for key in ('node_id', 'execution_id', 'iteration_path', 'attempt')} + if (item.get('workflow_result') or {}).get('contract_version') == 'workflow-result-v2' else {}), ) + if structured_run: + from functions_workflow_execution_history import workflow_execution_history + + cursor = None + while True: + page = workflow_execution_history(workflow, run_id, reader_user_id=reader_user_id, cursor=cursor, limit=100) + cursor = page["next_cursor"] + if cursor is None: + break def _require_completed_result(envelope, *, allow_partial=False): - if envelope.get("contract_version") != WORKFLOW_RESULT_CONTRACT_VERSION: + if envelope.get("contract_version") not in {WORKFLOW_RESULT_CONTRACT_VERSION, "workflow-result-v2"}: raise ValueError("This workflow task result version is not supported.") state = (envelope.get("execution") or {}).get("status") validation = (envelope.get("validation") or {}).get("status") @@ -522,14 +575,19 @@ def persist_workflow_task_result(envelope, *, workflow, run_id, task_id, setting save_result=None): """Commit independently readable sections, then their small result manifest.""" if save_result is None: - save_result = save_workflow_task_result + save_result = save_workflow_node_result if envelope.get("contract_version") == "workflow-result-v2" else save_workflow_task_result if settings is None: # Production callers need the actual quota, including paged result sections. from functions_settings import get_settings settings = get_settings() + selectors = {} + if envelope.get("contract_version") == "workflow-result-v2": + from functions_workflow_node_results import result_selectors + + selectors = result_selectors(envelope["identity"]) return persist_result_sections( envelope, - lambda section: save_result(workflow, run_id, task_id, section, settings=settings), + lambda section: save_result(workflow, run_id, task_id, section, settings=settings, **selectors), max_result_bytes=_quota_bytes(settings or {}), ) @@ -593,8 +651,15 @@ def persist_result_sections( sections["presentation"] = {"kind": "presentation", "value": envelope["presentation"]} sections["diagnostics"] = {"kind": "diagnostics", "value": envelope["diagnostics"]} manifest["outputs"] = {} + if envelope.get("contract_version") == "workflow-result-v2" and len(envelope.get("consumed_inputs") or []) > 100: + index = _save_record_pages(envelope, "lineage", envelope["consumed_inputs"], save_section, max_result_bytes) + if index: + manifest.pop("consumed_inputs", None) + manifest["consumed_inputs_index"] = index for name, output in sections.items(): - if output["kind"] in {"records", "evidence"} and envelope.get("analysis_access") and isinstance(output["value"], list): + if output["kind"] in {"records", "evidence", "document_results"} and ( + envelope.get("analysis_access") or envelope.get("contract_version") == "workflow-result-v2" + ) and isinstance(output["value"], list): paged = _save_record_pages(envelope, name, output["value"], save_section, max_result_bytes, output["kind"]) if paged is not None: manifest["outputs"][name] = paged @@ -615,7 +680,7 @@ def persist_result_sections( def read_result_records(manifest, name, load_section, *, offset=0, limit=None): """Read a complete-record range without loading unrelated record pages.""" output = (manifest.get("outputs") or {}).get(name) - if not isinstance(output, Mapping) or output.get("kind") not in {"records", "evidence"}: + if not isinstance(output, Mapping) or output.get("kind") not in {"records", "evidence", "document_results"}: raise ValueError("The requested output is not a record collection.") if type(offset) is not int or offset < 0 or (limit is not None and (type(limit) is not int or limit < 1)): raise ValueError("The record range is invalid.") @@ -705,7 +770,7 @@ def iter_result_records(manifest, name, load_section): def load_workflow_task_input(workflow, run_id, task_id, reference, *, load_result=load_workflow_task_result, reader_user_id=None, source_resolver=None, output_name="authoritative", - allow_partial=False, bounded=False): + allow_partial=False, bounded=False, **selectors): """Read one exact final representation and its immutable consumption receipt. The default selects the producer's authoritative output. Explicit names @@ -713,6 +778,19 @@ def load_workflow_task_input(workflow, run_id, task_id, reference, Partial accepted Analyze findings require an explicit reporting opt-in; neither that opt-in nor a named output admits pending or invalid results. """ + if selectors: + from functions_workflow_identity import workflow_node_identity + from functions_workflow_node_results import load_workflow_node_input + + return load_workflow_node_input( + workflow, run_id, workflow_node_identity( + workflow, run_id, selectors.get("node_id"), selectors.get("execution_id"), selectors.get("attempt"), + task_id=task_id, iteration_path=selectors.get("iteration_path"), + ), reference, output_name=output_name, allow_partial=allow_partial, + reader_user_id=reader_user_id, + load_result=load_workflow_node_result if load_result is load_workflow_task_result else load_result, + source_resolver=source_resolver, + ) final_kinds = {"text": "text", "records": "records", "json": "json", "documents": "document_results"} if not isinstance(output_name, str) or output_name not in {"authoritative", *final_kinds}: raise ValueError("The requested workflow output must be an exact final representation.") @@ -779,7 +857,7 @@ def load_workflow_task_input(workflow, run_id, task_id, reference, ) output = { "contract_version": manifest["contract_version"], "producer": manifest["identity"], - "output_name": name, "kind": "records", "value": records, + "output_name": name, "kind": output_descriptor["kind"], "value": records, } else: output = load_result(workflow, run_id, task_id, output_ref) @@ -819,6 +897,10 @@ def workflow_result_summary(envelope, reference): "consumed_inputs": _json_copy(envelope.get("consumed_inputs") or []), "workflow_validation": _json_copy(envelope.get("workflow_validation") or {}), } + if envelope.get("contract_version") == "workflow-result-v2": + summary["producer"] = _json_copy(envelope["identity"]) + if envelope.get("consumed_inputs_index"): + summary["consumed_input_count"] = envelope["consumed_inputs_index"]["record_count"] if envelope.get("analysis_access") or any( item.get("analysis_result") for item in envelope.get("consumed_inputs") or [] if isinstance(item, Mapping) diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index c58ad53b6..c2e7d5122 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -9935,7 +9935,9 @@ def _resolve_workflow_task_runner(workflow, task, settings, actor_user_id=None): return execution_workflow, runner_audit -def _workflow_task_run_item_id(run_id, task_id): +def _workflow_task_run_item_id(run_id, task_id, execution_id=None): + if execution_id is not None: + return str(uuid.uuid5(uuid.NAMESPACE_URL, f'workflow-execution:{run_id}:{execution_id}')) return str(uuid.uuid5(uuid.NAMESPACE_URL, f'workflow-task:{run_id}:{task_id}')) @@ -9983,8 +9985,14 @@ def _save_workflow_task_run_item( for field in ('prompt_tokens', 'completion_tokens', 'total_tokens', 'request_count') if isinstance(token_usage.get(field), (int, float)) } + execution_identity = {} + if workflow.get('definition_version') == 3: + execution = current_workflow_execution() + if execution is None or execution.node is None or execution.node.get('task_id') != task_id: + raise ValueError('A structured task projection requires its exact current execution.') + execution_identity = {**execution.selectors(), 'attempt': int(attempt_count or 0)} item = { - 'id': _workflow_task_run_item_id(run_id, task_id), + 'id': _workflow_task_run_item_id(run_id, task_id, execution_identity.get('execution_id')), 'type': 'workflow_run_item', 'item_type': 'task', 'run_id': run_id, @@ -9993,6 +10001,7 @@ def _save_workflow_task_run_item( 'group_id': _get_workflow_group_id(workflow) or None, 'workflow_name': workflow.get('name'), 'task_id': task_id, + **execution_identity, 'task_type': str(task.get('type') or 'instructions').strip(), 'task_order': int(task.get('order') or 0), 'label': str(task.get('name') or f"Task {task.get('order') or ''}").strip(), @@ -10138,6 +10147,12 @@ def _merge_workflow_task_execution_results(task_results): final_source = (successful_results[-1].get('result') or {}) if successful_results else {} merged_result = dict(final_source) + publications = [ + item["result"]["publication"] for item in task_results + if isinstance(item.get("result"), dict) and isinstance(item["result"].get("publication"), dict) + ] + if publications: + merged_result["publication"] = publications[-1] merged_result['reply'] = '\n'.join(reply_sections).strip() merged_result['task_results'] = [ { @@ -10216,9 +10231,12 @@ def authorize(): durable = current_workflow_execution() options = {} if durable is not None: - token_record = durable.cache(f'analysis-token:{task_id}', {'token': uuid.uuid4().hex}) + selectors = durable.selectors() if workflow.get('definition_version') == 3 else {} + token_key = f"analysis-token:{task_id}:{selectors['attempt']}" if selectors else f'analysis-token:{task_id}' + token_record = durable.cache(token_key, {'token': uuid.uuid4().hex}) options['attempt_token'] = token_record['token'] options['recover_running_unit'] = lambda: durable.may_recover_analysis_unit(task_id) + options.update(selectors) checkpoints = analysis_checkpoints_for_workflow( workflow, run_id, task_id, user_id=actor_user_id, authorize=authorize, settings=settings, **options, ) @@ -10237,7 +10255,7 @@ def _execute_workflow_task_sequence( actor_user_id=None, ): definition_version = workflow.get('definition_version', 1) - if type(definition_version) is not int or definition_version not in {1, 2}: + if type(definition_version) is not int or definition_version not in {1, 2, 3}: raise ValueError('This workflow definition requires a newer execution engine.') tasks = list(workflow.get('tasks') or []) error_handling = workflow.get('error_handling') if isinstance(workflow.get('error_handling'), dict) else {} @@ -10249,8 +10267,18 @@ def _execute_workflow_task_sequence( completed_results = {} reference_cache = {} actor_id = str(actor_user_id or workflow.get('user_id') or '') - advanced_definition = workflow.get('definition_version') == 2 + advanced_definition = workflow.get('definition_version') in {2, 3} + structured_definition = definition_version == 3 durable = current_workflow_execution() + flow_runner = None + if structured_definition: + from functions_workflow_flow_runner import WorkflowFlowRunner + + if durable is None or not hasattr(durable, 'selectors'): + raise ValueError('Structured workflows require the durable schema-2 execution engine.') + flow_runner = WorkflowFlowRunner( + workflow, run_id, durable, task_results, actor_user_id=actor_id, settings=settings, + ) if durable is not None: reference_cache = durable.snapshot('shared_references') or {} control = durable.check() @@ -10266,11 +10294,21 @@ def _execute_workflow_task_sequence( used_reference_ids.update(all_reference_ids if selected is None else selected) for reference in workflow.get('reference_inputs') or []: if reference['id'] in used_reference_ids: - reference_cache[reference['id']] = load_workflow_reference( - workflow, reference, actor_user_id=actor_id, - snapshot=reference_cache.get(reference['id']), - ) - if durable is not None: + if structured_definition: + try: + reference_cache[reference['id']] = durable.freeze_reference( + reference, lambda **snapshot: load_workflow_reference( + workflow, reference, actor_user_id=actor_id, **snapshot, + ), + ) + except (AnalysisResultUnavailable, WorkflowInputError): + durable._pause('shared_references', workflow.get('definition_revision') or '') + else: + reference_cache[reference['id']] = load_workflow_reference( + workflow, reference, actor_user_id=actor_id, + snapshot=reference_cache.get(reference['id']), + ) + if durable is not None and not structured_definition: durable.cache('shared_references', reference_cache) def raise_if_cancelled(): @@ -10278,7 +10316,7 @@ def raise_if_cancelled(): if callable(cancel_check): cancel_check(workflow, run_id) - for task_index, raw_task in enumerate(tasks): + for task_index, raw_task in enumerate(flow_runner.tasks() if flow_runner else tasks): raise_if_cancelled() task = dict(raw_task or {}) task['order'] = task_index + 1 @@ -10290,9 +10328,17 @@ def raise_if_cancelled(): if checkpoint is not None: saved_result = checkpoint['result'] summary = saved_result['workflow_result'] - authorize_workflow_task_result_read( - workflow, run_id, task_id, summary['result_ref'], reader_user_id=actor_id, - ) + try: + _, source_access = authorize_workflow_task_result_read( + workflow, run_id, task_id, summary['result_ref'], reader_user_id=actor_id, + **(durable.selectors(attempt=summary['producer']['attempt']) if structured_definition else {}), + ) + except AnalysisResultUnavailable: + if not structured_definition: + raise + durable._pause(task_unit_key, summary['result_ref']['sha256']) + if structured_definition and source_access.get('source_snapshot_changed'): + durable._pause(task_unit_key, summary['result_ref']['sha256']) task_results.append(checkpoint) completed_results[task_id] = { 'run_id': run_id, 'result_ref': summary['result_ref'], @@ -10339,17 +10385,23 @@ def raise_if_cancelled(): try: if task.get('publication') is not None: task_stage = 'publication' + publication_inputs = flow_runner.resolve(task['inputs']) if flow_runner else None task_result, consumed_inputs = workflow_unit( task_unit_key, lambda: _execute_workflow_analysis_publication( workflow, run_id, task, previous_task_id, previous_result_ref, actor_user_id=actor_user_id, + explicit_inputs=publication_inputs['bound_inputs'] if publication_inputs else None, ), - inputs={'task': task, 'producer_task_id': previous_task_id, 'result_ref': previous_result_ref}, + inputs=({'task': task, 'consumed_inputs': publication_inputs['consumed_inputs']} + if publication_inputs is not None else + {'task': task, 'producer_task_id': previous_task_id, 'result_ref': previous_result_ref}), approval=task.get('approval'), ) if durable is not None: - attempt_count = durable.check()['units'][task_unit_key]['attempt'] + attempt_count = durable.unit(task_unit_key)['attempt'] + if flow_runner: + consumed_inputs = flow_runner._receipts([*consumed_inputs, *flow_runner.control_receipts]) attempt_workflow = {**workflow, 'consumed_inputs': consumed_inputs} runner_audit = {'requested_mode': 'publication', 'resolved_type': 'publication'} task_error = '' @@ -10369,15 +10421,22 @@ def raise_if_cancelled(): reference_sources = [] if advanced_definition: inputs = resolve_workflow_task_inputs( - workflow, task, completed_results, previous_task_id=previous_task_id, + workflow, {**task, 'inputs': []} if structured_definition else task, + completed_results, previous_task_id=previous_task_id, load_output=lambda *args, **kwargs: load_workflow_task_input( *args, reader_user_id=actor_id, **kwargs, ), load_reference=lambda reference, **kwargs: load_workflow_reference( workflow, reference, actor_user_id=actor_id, **kwargs, + ) if not structured_definition else durable.freeze_reference( + reference, lambda **snapshot: load_workflow_reference( + workflow, reference, actor_user_id=actor_id, **snapshot, + ), ), reference_cache=reference_cache, ) + if flow_runner: + inputs.update(flow_runner.resolve(task['inputs'])) previous_input = inputs['task_context'] consumed_inputs = inputs['consumed_inputs'] reference_context = inputs['reference_context'] @@ -10395,8 +10454,8 @@ def raise_if_cancelled(): resolved_workflow, task, previous_reply='' if isinstance(previous_input, SavedAnalysisInput) else previous_input, - include_document_action=task_index == 0, - include_file_sync_context=task_index == 0, + include_document_action=task_index == 0 and not structured_definition, + include_file_sync_context=task_index == 0 and not structured_definition, ) attempt_workflow['consumed_inputs'] = consumed_inputs attempt_workflow['workflow_reference_sources'] = reference_sources @@ -10426,7 +10485,7 @@ def raise_if_cancelled(): 'kind': 'workflow', 'workflow_id': workflow['id'], 'run_id': run_id, 'task_id': task_id, } - if _get_document_action_config(attempt_workflow).get('type') == DOCUMENT_ACTION_TYPE_ANALYZE: + if not structured_definition and _get_document_action_config(attempt_workflow).get('type') == DOCUMENT_ACTION_TYPE_ANALYZE: if analysis_checkpoints is None: analysis_checkpoints = _prepare_workflow_analysis_checkpoints( workflow, run_id, task_id, actor_user_id or workflow.get('user_id'), settings, @@ -10453,20 +10512,33 @@ def raise_if_cancelled(): and not attempt_workflow.get('chat_capabilities_enabled') and (attempt_workflow.get('document_action') or {}).get('type') in {None, 'none'} ) + def dispatch_task(): + nonlocal analysis_checkpoints + if structured_definition: + attempt_workflow['_analysis_producer'].update(durable.selectors()) + if _get_document_action_config(attempt_workflow).get('type') == DOCUMENT_ACTION_TYPE_ANALYZE: + analysis_checkpoints = _prepare_workflow_analysis_checkpoints( + workflow, run_id, task_id, actor_id, settings, + ) + attempt_workflow['_analysis_checkpoints'] = analysis_checkpoints + return _execute_workflow_dispatch( + attempt_workflow, settings, conversation_id, run_id, thought_tracker, + {} if attempt_workflow.get('_saved_analysis_input_only') else url_access_context, + file_sync_result=file_sync_result, + ) + with workflow_context_budget_scope(attempt_workflow): task_result = workflow_unit( task_unit_key, - lambda: _execute_workflow_dispatch( - attempt_workflow, settings, conversation_id, run_id, - thought_tracker, {} if attempt_workflow.get('_saved_analysis_input_only') else url_access_context, - file_sync_result=file_sync_result, - ), + dispatch_task, inputs=operation_inputs, replay_safe=replay_safe, approval=task.get('approval'), ) if durable is not None: - saved_unit = (durable.check().get('units') or {}).get(task_unit_key) or {} + saved_unit = durable.unit(task_unit_key) attempt_count = int(saved_unit.get('attempt') or attempt_count) + if structured_definition: + attempt_workflow['_analysis_producer'].update(durable.selectors(attempt=attempt_count)) raise_if_workflow_context_blocked(attempt_workflow) task_error = '' runner_audit = dict(runner_audit) @@ -10482,6 +10554,8 @@ def raise_if_cancelled(): task_result = None blocked_audit = ((attempt_workflow or {}).get('context_budget') or {}).get('blocked_request') safe_error = WorkflowContextBudgetError(blocked_audit) if blocked_audit else exc + if structured_definition and task_stage == 'input' and isinstance(safe_error, AnalysisResultUnavailable): + durable._pause(task_unit_key, durable.unit(task_unit_key).get('input_digest', '')) log_event( '[WORKFLOW_RUNNER] Task execution failed', extra={'run_id': run_id, 'task_id': task_id, 'attempt': attempt_count, @@ -10538,6 +10612,9 @@ def raise_if_cancelled(): task_result, workflow=workflow, run_id=run_id, task=task, attempt_count=attempt_count, ) + if structured_definition and _get_document_action_config(attempt_workflow).get('type') == DOCUMENT_ACTION_TYPE_ANALYZE: + if analysis_checkpoints is None: + analysis_checkpoints = _prepare_workflow_analysis_checkpoints(workflow, run_id, task_id, actor_id, settings) if durable is not None and envelope['execution']['status'] == 'pending': try: refreshed = reconcile_workflow_pending_output( @@ -10563,6 +10640,12 @@ def raise_if_cancelled(): result_summary=workflow_result_summary(pending_manifest, pending_ref), consumed_inputs=consumed_inputs, context_budget=context_budget, ) + if structured_definition: + pending_summary = workflow_result_summary(pending_manifest, pending_ref) + durable.record_execution(state='waiting_output', attempt=attempt_count, + workflow_result=pending_summary, consumed_inputs=consumed_inputs) + durable._attempt(attempt_count, state='waiting_output', workflow_result=pending_summary, + consumed_inputs=consumed_inputs) durable.wait_for_output(task_unit_key, pending_workflow_output_references(task_result)) task_result = refreshed durable.replace_unit_result(task_unit_key, task_result) @@ -10604,6 +10687,7 @@ def save_analysis_section(bound_workflow, bound_run, bound_task, section, **_kwa result_summary = workflow_result_summary(manifest, result_ref) authorize_workflow_task_result_read( workflow, run_id, task_id, result_ref, manifest=manifest, reader_user_id=actor_id, + **(durable.selectors(attempt=attempt_count) if structured_definition else {}), ) task_result['workflow_result'] = result_summary task_result['context_budget'] = context_budget @@ -10661,6 +10745,11 @@ def save_analysis_section(bound_workflow, bound_run, bound_task, section, **_kwa 'runner': runner_audit, 'consumed_inputs': consumed_inputs, }) + if structured_definition: + durable.finish_node( + state=task_status, attempt=attempt_count, workflow_result=result_summary, + workflow_validation=validation, consumed_inputs=consumed_inputs, + ) if durable is not None and validation['eligible']: durable.cache(f'task-result:{task_id}', task_results[-1]) if thought_tracker and run_id: @@ -10734,16 +10823,32 @@ def save_analysis_section(bound_workflow, bound_run, bound_task, section, **_kwa status='failed', ) if error_strategy != 'continue': + if structured_definition: + durable.finish_node(state='failed', attempt=max(1, attempt_count), reason_code='task_failed', + consumed_inputs=(attempt_workflow or {}).get('consumed_inputs') or []) raise RuntimeError( f"Workflow task '{task.get('name') or task_id}' failed after {attempt_count} attempt(s): {task_error}" ) - return _merge_workflow_task_execution_results(task_results) + merged = _merge_workflow_task_execution_results(task_results) + if flow_runner is not None: + for task_result in merged.get('task_results') or []: + producer = (task_result.get('workflow_result') or {}).get('producer') or {} + task_result.update({key: producer[key] for key in ('execution_id', 'node_id', 'iteration_path', 'attempt') if key in producer}) + merged['workflow_outputs'] = flow_runner.final_outputs + if flow_runner.finished and not flow_runner.failed: + merged['workflow_outcome'] = { + **merged.get('workflow_outcome', {}), + 'status': 'completed_partial' if flow_runner.partial else 'completed', + 'success': True, + } + durable.set_node(None, workflow['flow']['id']) + return merged def _execute_workflow_analysis_publication( workflow, run_id, task, previous_task_id, previous_result_ref, *, actor_user_id=None, - result_reader=None, publish=None, + result_reader=None, publish=None, explicit_inputs=None, ): """Select only a committed ancestor artifact, before resolving a model or its context.""" publication = task.get('publication') @@ -10754,10 +10859,31 @@ def _execute_workflow_analysis_publication( from functions_personal_workflows import normalize_workflow_publication publication = normalize_workflow_publication(publication) - if not previous_task_id or not previous_result_ref: - raise WorkflowResultNotReadyError('Publication needs an existing saved upstream analysis artifact.') reader = result_reader or authorize_workflow_task_result_read actor = actor_user_id or workflow.get('user_id') + if workflow.get('definition_version') == 3: + if not isinstance(explicit_inputs, list) or len(explicit_inputs) != 1: + raise WorkflowResultNotReadyError('Publication requires exactly one explicit saved analysis input.') + selected = explicit_inputs[0] + for _ in range(256): + if selected['producer'].get('task_id'): + break + manifest, _ = reader( + workflow, run_id, None, selected['result_ref'], reader_user_id=actor, + **{key: selected['producer'][key] for key in ('node_id', 'execution_id', 'iteration_path', 'attempt')}, + ) + descriptor = (manifest.get('outputs') or {}).get(selected['output_name']) or {} + selected = descriptor.get('selected_producer') + if not isinstance(selected, dict): + raise WorkflowResultNotReadyError('Select a single native saved analysis output for publication.') + else: + raise WorkflowResultNotReadyError('The selected join provenance is invalid.') + previous_task_id = selected['producer'].get('task_id') + previous_result_ref = selected['result_ref'] + if not previous_task_id: + raise WorkflowResultNotReadyError('Select the native saved analysis task explicitly for publication.') + if not previous_task_id or not previous_result_ref: + raise WorkflowResultNotReadyError('Publication needs an existing saved upstream analysis artifact.') pending = [(previous_task_id, previous_result_ref)] seen = set() while pending: @@ -10770,6 +10896,8 @@ def _execute_workflow_analysis_publication( raise WorkflowResultNotReadyError('The artifact lineage is unavailable.') manifest, _ = reader( workflow, run_id, producer_task, reference, reader_user_id=actor, + **({key: selected['producer'][key] for key in ('node_id', 'execution_id', 'iteration_path', 'attempt')} + if workflow.get('definition_version') == 3 else {}), ) matching = [ artifact for artifact in manifest.get('artifacts') or [] @@ -10792,13 +10920,23 @@ def _execute_workflow_analysis_publication( producer = {'kind': 'workflow', **{ name: manifest['identity'][name] for name in ('workflow_id', 'run_id', 'task_id') }} + publication_request_id = f"workflow-publication:{workflow['id']}:{run_id}:{task['id']}" + if workflow.get('definition_version') == 3: + if manifest.get('analysis_origin') is not True: + raise WorkflowResultNotReadyError('Publication requires a native saved analysis producer.') + producer.update({key: manifest['identity'][key] for key in ('node_id', 'execution_id', 'iteration_path', 'attempt')}) + execution = current_workflow_execution() + publication_request_id = ( + f"workflow-publication:v3:{execution.execution_id()}:" + f"{producer['execution_id']}:{producer['attempt']}" + ) result = (publish or publish_workflow_analysis_artifact)( actor, publication=publication, artifact_reference={ 'conversation_id': artifact.get('conversation_id'), 'artifact_message_id': artifact.get('artifact_message_id'), 'producer': producer, }, - request_id=f"workflow-publication:{workflow['id']}:{run_id}:{task['id']}", + request_id=publication_request_id, ) state = (result.get('publication') or {}).get('state') if state == 'pending_approval': @@ -10806,11 +10944,17 @@ def _execute_workflow_analysis_publication( elif state in {'uncertain', 'approval_failed'}: result['execution_status'] = 'blocked' output_name = manifest['authoritative_output'] - return result, [{ + native_receipt = { 'producer': manifest['identity'], 'output_name': output_name, 'result_ref': dict(reference), 'output_ref': manifest['outputs'][output_name]['result_ref'], 'analysis_result': True, - }] + } + return result, ( + [*explicit_inputs, native_receipt] if workflow.get('definition_version') == 3 + and explicit_inputs[0]['producer'] != native_receipt['producer'] else [native_receipt] + ) + if workflow.get('definition_version') == 3: + break for consumed in manifest.get('consumed_inputs') or []: producer = consumed.get('producer') or {} if producer.get('workflow_id') != workflow.get('id') or producer.get('run_id') != run_id: @@ -10976,11 +11120,17 @@ def _run_personal_workflow_impl(workflow, trigger_source='manual', user_roles=No } if durable is not None: control = durable.check() - existing_progress = ( - durable.load_result( - workflow, run_id, 'runtime:run-record', control['run_record_ref'], - )['run_record'] if control.get('run_record_ref') else control.get('run_record') or {} - ) + existing_progress = control.get('run_record') or {} + if control.get('run_record_ref'): + reference = control['run_record_ref'] + if control.get('schema_version') == 2: + existing_progress = durable.load_result( + workflow, run_id, None, reference['result_ref'], **reference['selectors'], + )['run_record'] + else: + existing_progress = durable.load_result( + workflow, run_id, 'runtime:run-record', reference, + )['run_record'] if not existing_progress: existing_progress = _get_workflow_run_record(workflow, run_id) or {} run_record.update(existing_progress) @@ -11192,6 +11342,8 @@ def _run_personal_workflow_impl(workflow, trigger_source='manual', user_roles=No 'agent_display_name': execution_result.get('agent_display_name'), 'analysis_coverage': execution_result.get('analysis_coverage') or {}, 'task_results': execution_result.get('task_results') or [], + **({'workflow_outputs': execution_result.get('workflow_outputs') or []} + if workflow.get('definition_version') == 3 else {}), 'task_error_count': int(execution_result.get('task_error_count') or 0), 'url_access': execution_result.get('url_access') or {}, 'source_review': execution_result.get('source_review') or {}, diff --git a/application/single_app/functions_workflow_runtime.py b/application/single_app/functions_workflow_runtime.py index 8bbbc4314..452347b25 100644 --- a/application/single_app/functions_workflow_runtime.py +++ b/application/single_app/functions_workflow_runtime.py @@ -17,8 +17,11 @@ from functions_appinsights import log_event from functions_workflow_definitions import WORKFLOW_DEFINITION_FIELDS, workflow_definition_revision from functions_workflow_execution import DurableWorkflowExecution, WorkflowSuspended, workflow_execution_scope +from functions_workflow_structured_execution import StructuredWorkflowExecution +from functions_workflow_flow import compile_workflow_flow from functions_workflow_readiness import WorkflowOutputUnavailable, workflow_outputs_ready from functions_workflow_result_store import delete_workflow_run_results, load_workflow_task_result, save_workflow_task_result +from functions_workflow_result_store import load_workflow_runtime_result, save_workflow_runtime_result from functions_workflow_runtime_store import ( WorkflowRuntimeConflict, WorkflowRuntimeLease, @@ -125,6 +128,10 @@ def queue_durable_workflow_run(workflow, *, actor_user_id, trigger_source="manua if current.get("durable_execution") is not True: raise ValueError("Durable execution is not enabled for this workflow.") _authorize_execution(current, actor_user_id, settings) + if type(current.get("definition_version", 1)) is not int or current.get("definition_version", 1) not in {1, 2, 3}: + raise ValueError("This workflow definition requires a newer execution engine.") + if current.get("definition_version") == 3: + compile_workflow_flow(current) if request_id is not None and not isinstance(request_id, str): raise ValueError("A workflow request identifier must be a UUID string.") request_id = str(uuid.UUID(request_id)) if request_id is not None else str(uuid.uuid4()) @@ -143,9 +150,13 @@ def queue_durable_workflow_run(workflow, *, actor_user_id, trigger_source="manua if existing_control.get("deleted"): raise WorkflowRuntimeConflict("tombstoned", "This workflow run was deleted.") snapshot_ref = existing_control["snapshot_ref"] - snapshot = load_workflow_task_result(current, run_id, "runtime:definition", snapshot_ref) + snapshot = store.run_definition() if existing_control.get("schema_version") == 2 else load_workflow_task_result(current, run_id, "runtime:definition", snapshot_ref) else: - snapshot_ref = save_workflow_task_result(current, run_id, "runtime:definition", snapshot, settings=settings) + snapshot_ref = ( + save_workflow_runtime_result(snapshot, run_id, snapshot, settings=settings) + if snapshot.get("definition_version") == 3 else + save_workflow_task_result(current, run_id, "runtime:definition", snapshot, settings=settings) + ) control = store.initialize( snapshot_ref=snapshot_ref, definition_revision=snapshot["definition_revision"], actor_user_id=actor_user_id, request_id=request_id, @@ -159,6 +170,7 @@ def queue_durable_workflow_run(workflow, *, actor_user_id, trigger_source="manua "workspace_type": "group" if current.get("group_id") else "personal", "trigger_source": trigger_source, "triggered_by": actor_user_id, "durable_execution": True, "status": control["state"], "success": False, + "definition_version": snapshot.get("definition_version", 1), "started_at": control.get("created_at") or _now(), "completed_at": None, "definition_revision": snapshot["definition_revision"], } @@ -189,7 +201,11 @@ def _project_runtime_run(services, workflow, run_id, control, *, result=None, at if latest["version"] > control["version"]: control, result = latest, None if result is None and control.get("completion_ref"): - result = load_workflow_task_result(workflow, run_id, "runtime:completion", control["completion_ref"]) + result = ( + load_workflow_runtime_result(workflow, run_id, control, control["completion_ref"]) + if control.get("schema_version") == 2 else + load_workflow_task_result(workflow, run_id, "runtime:completion", control["completion_ref"]) + ) existing = services["runs"].read_item(item=run_id, partition_key=services["partition"]) body = {key: value for key, value in existing.items() if not key.startswith("_")} if result: @@ -272,7 +288,9 @@ def decide_workflow_runtime(workflow, run_id, data, *, actor_user_id, resume=Fal workflow_runtime_status(workflow, run_id, reader_user_id=actor_user_id) store = workflow_runtime_store(workflow, run_id) control = store.read() - snapshot = load_workflow_task_result(workflow, run_id, "runtime:definition", control["snapshot_ref"]) + snapshot = store.run_definition() if control.get("schema_version") == 2 else load_workflow_task_result(workflow, run_id, "runtime:definition", control["snapshot_ref"]) + if snapshot.get("definition_version") == 3: + compile_workflow_flow(snapshot) current = services["load_workflow"]() if not current or workflow_definition_revision(current) != control["definition_revision"]: raise WorkflowRuntimeConflict("workflow_definition_changed") @@ -313,6 +331,7 @@ def continue_durable_workflow_run(workflow, run_id): services = _services(workflow) store = workflow_runtime_store(workflow, run_id) control = store.read() + control = store.expire_deadline() current = services["load_workflow"]() if not current or current.get("active_run_id") != run_id: return None @@ -343,15 +362,18 @@ def continue_durable_workflow_run(workflow, run_id): "choices": ["resume", "cancel"], }) return _project_runtime_run(services, current, run_id, control) - snapshot = load_workflow_task_result(current, run_id, "runtime:definition", control["snapshot_ref"]) + snapshot = store.run_definition() if control.get("schema_version") == 2 else load_workflow_task_result(current, run_id, "runtime:definition", control["snapshot_ref"]) if workflow_definition_revision(snapshot) != control["definition_revision"]: raise WorkflowRuntimeConflict("workflow_definition_changed") + if snapshot.get("definition_version") == 3: + compile_workflow_flow(snapshot) if not snapshot.get("tasks"): snapshot["tasks"] = [{ "id": "legacy-task", "name": "Workflow task", "type": "instructions", "instructions": snapshot["task_prompt"], "runner": {"type": "inherit"}, }] - execution = DurableWorkflowExecution(store, lease, snapshot, run_id, settings=settings) + controller = StructuredWorkflowExecution if snapshot.get("definition_version") == 3 else DurableWorkflowExecution + execution = controller(store, lease, snapshot, run_id, settings=settings) result = None with workflow_execution_scope(execution): try: @@ -361,8 +383,10 @@ def continue_durable_workflow_run(workflow, run_id): actor_user_id=control["actor_user_id"], run_id=run_id, ) execution.check() - reference = save_workflow_task_result( - snapshot, run_id, "runtime:completion", result, settings=settings, + reference = ( + save_workflow_runtime_result(snapshot, run_id, result, settings=settings) + if snapshot.get("definition_version") == 3 else + save_workflow_task_result(snapshot, run_id, "runtime:completion", result, settings=settings) ) final_state = (result.get("run") or {}).get("status") or "failed" if final_state not in RUNTIME_TERMINAL_STATES: @@ -394,7 +418,8 @@ def check_durable_workflows_once(limit=20): query=( "SELECT TOP @limit * FROM c WHERE c.durable_execution = true " "AND IS_DEFINED(c.active_run_id) AND c.active_run_id != '' " - "AND c.status IN ('queued','running','cancelling','waiting_output') ORDER BY c.updated_at ASC" + "AND (c.status IN ('queued','running','cancelling','waiting_output') " + "OR (c.definition_version = 3 AND c.status IN ('waiting_approval','waiting_recovery'))) ORDER BY c.updated_at ASC" ), parameters=[{"name": "@limit", "value": limit}], enable_cross_partition_query=True, diff --git a/application/single_app/functions_workflow_runtime_store.py b/application/single_app/functions_workflow_runtime_store.py index 1c60264ad..14346c747 100644 --- a/application/single_app/functions_workflow_runtime_store.py +++ b/application/single_app/functions_workflow_runtime_store.py @@ -1,13 +1,13 @@ # functions_workflow_runtime_store.py -"""Durable workflow runtime-control journal for milestone 3. +"""Durable workflow runtime control and schema-2 paged execution journal. -Version: 0.261.111 +Version: 0.261.116 Implemented in: 0.261.111 -This module owns a single private control row in the existing workflow -run-items container. It records runtime state, leases, gates, decisions, and -small safe metadata only; schedulers/runners remain responsible for execution, -authorization, task replay policy, and large result payload storage. +The single private control row retains the existing lease/CAS identity. +Schema 1 keeps legacy unit maps; schema 2 keeps cursor/counters and stores +execution, attempt, unit and decision records separately in the same partition. +Schedulers/runners still own execution, authorization and task replay policy. """ import json @@ -19,6 +19,8 @@ from azure.core import MatchConditions from azure.cosmos import exceptions as cosmos_exceptions +from functions_workflow_journal import WorkflowJournalMixin +from functions_workflow_identity import workflow_execution_id CONTROL_ID = "workflow-runtime:v1" @@ -55,6 +57,7 @@ FORBIDDEN_PAYLOAD_KEY_PARTS = ("token", "secret", "password", "connection") FORBIDDEN_GATE_KEY_PARTS = FORBIDDEN_PAYLOAD_KEY_PARTS + ("prompt", "result", "payload", "content", "raw") GATE_ALLOWED_KEYS = frozenset({ + "execution_id", "node_id", "iteration_path", "definition_revision", "id", "kind", "unit_id", @@ -427,6 +430,7 @@ def public_projection(control): if isinstance(gate.get("metadata"), dict): safe_gate["metadata"] = _safe_ref(gate["metadata"]) return { + "schema_version": control.get("schema_version", 1), "version": control.get("version"), "state": control.get("state"), "control_state": control.get("state"), @@ -435,11 +439,20 @@ def public_projection(control): "snapshot_ref": _safe_ref(control.get("snapshot_ref")), "phase": control.get("phase"), "progress": control.get("progress"), + **({ + "limits": { + "max_executions": control["max_executions"], + "admitted_count": int(control.get("admitted_count") or 0), + "deadline_at": control["deadline_at"], + "deadline_seconds": control["deadline_seconds"], + "waits_count": True, + }, + } if control.get("schema_version") == 2 else {}), "deleted": bool(control.get("deleted")), "gate": safe_gate, "memory": { - "unit_count": len(units), - "completed_unit_count": sum( + "unit_count": int((control.get("journal_counts") or {}).get("unit") or 0) if control.get("schema_version") == 2 else len(units), + "completed_unit_count": int(control.get("completed_unit_count") or 0) if control.get("schema_version") == 2 else sum( 1 for unit in units.values() if isinstance(unit, dict) and (unit.get("state") or unit.get("status")) == "completed" ), @@ -448,6 +461,10 @@ def public_projection(control): for key in sorted(unit_items) ], "decisions": decisions[-MAX_DECISIONS:], + **({ + "execution_count": int((control.get("journal_counts") or {}).get("execution") or 0), + "decision_count": int((control.get("journal_counts") or {}).get("decision") or 0), + } if control.get("schema_version") == 2 else {}), }, "can_resume": control.get("state") in RESUMABLE_STATES and not control.get("deleted"), } @@ -456,7 +473,7 @@ def public_projection(control): workflow_runtime_projection = public_projection -class WorkflowRuntimeStore: +class WorkflowRuntimeStore(WorkflowJournalMixin): """Dependency-injected durable control journal for one authorized workflow run. ``container`` is the existing personal/group workflow_run_items Cosmos @@ -475,6 +492,7 @@ def __init__(self, container, workflow, run_id, *, clock=None): raise ValueError("A workflow run-items container is required.") self.container = container self.identity = _identity(workflow, run_id) + self.workflow = deepcopy(workflow) self.clock = clock or _now def _now(self): @@ -487,7 +505,7 @@ def _verify_identity(self, control, *, allow_deleted=False): raise RuntimeConflict("identity_mismatch", "Workflow runtime identity does not match this run.") if control.get("id") != CONTROL_ID or control.get("type") != CONTROL_TYPE or control.get("item_type") != CONTROL_TYPE: raise RuntimeConflict("identity_mismatch", "Workflow runtime control row is invalid.") - if control.get("kind") != "run" or control.get("schema_version") != SCHEMA_VERSION: + if control.get("kind") != "run" or type(control.get("schema_version")) is not int or control.get("schema_version") not in {1, 2}: raise RuntimeConflict("identity_mismatch", "Workflow runtime control row version is invalid.") if control.get("deleted") and not allow_deleted: raise RuntimeConflict("not_found", "Workflow runtime control was deleted.") @@ -564,6 +582,57 @@ def read(self, *, allow_deleted=False): """Return the internal control row after identity and tombstone checks.""" return self._read_control(allow_deleted=allow_deleted) + def expire_deadline(self): + def mutator(current): + deadline = _parse_timestamp(current.get("deadline_at")) + if current.get("schema_version") != 2 or deadline is None or self._now() < deadline or current["state"] in TERMINAL_STATES | {"paused"}: + return NO_WRITE + return self._limit_pause(current, "deadline_exceeded") + + return self._mutate(mutator) + + def _limit_pause(self, current, code): + replacement = self._base_replacement(current) + node_id = (current.get("cursor") or {}).get("node_id") + replacement.update(state="paused", phase=code, lease=None, version=current["version"] + 1, gate={ + "id": uuid.uuid4().hex, "kind": "pause", "unit_id": node_id or "run-limits", + "input_digest": current["definition_revision"], "choices": ["cancel"], + "reason": ( + "The elapsed workflow deadline was reached, including time spent waiting. Cancel and start a new run." + if code == "deadline_exceeded" else + "The workflow execution admission limit was reached. Cancel and start a new run with an appropriate limit." + ), + }) + return replacement + + def pause_execution_limit(self, token, code): + if code not in {"deadline_exceeded", "execution_budget_exceeded"}: + raise RuntimeConflict("invalid_limit") + + def mutator(current): + self._assert_current_owned(current, token) + return self._limit_pause(current, code) + + return self._mutate(mutator) + + def run_definition(self): + from functions_workflow_definitions import workflow_definition_revision + from functions_workflow_result_store import load_workflow_task_result, load_workflow_runtime_result + + control = self._read_control() + snapshot = ( + load_workflow_runtime_result(self.workflow, self.identity["run_id"], control, control["snapshot_ref"]) + if control.get("schema_version") == 2 else + load_workflow_task_result(self.workflow, self.identity["run_id"], "runtime:definition", control["snapshot_ref"]) + ) + if ( + workflow_definition_revision(snapshot) != control["definition_revision"] + or snapshot.get("id") != self.identity["workflow_id"] or snapshot.get("user_id") != self.identity["user_id"] + or (snapshot.get("group_id") or None) != self.identity["group_id"] + ): + raise RuntimeConflict("workflow_definition_changed") + return snapshot + def write_record(self, token, record, *, immutable=False): """Fence a run-item payload write behind the live runtime-control lease. @@ -625,6 +694,20 @@ def initialize(self, *, snapshot_ref, definition_revision, actor_user_id, reques "lease": None, "deleted": False, } + if self.workflow.get("definition_version") == 3: + from functions_workflow_flow import compile_workflow_flow + + compiled = compile_workflow_flow(self.workflow) + control.update( + schema_version=2, cursor={"region_id": compiled["flow"]["id"], "node_id": None}, + snapshot_identity={"node_id": compiled["flow"]["id"], + "execution_id": workflow_execution_id(self.workflow, self.identity["run_id"], compiled["flow"]["id"]), + "attempt": 1, "iteration_path": []}, + admitted_count=0, journal_sequence=0, journal_counts={}, + max_executions=compiled["limits"]["max_executions"], + deadline_seconds=compiled["limits"]["deadline_seconds"], + deadline_at=_iso(self._now() + timedelta(seconds=compiled["limits"]["deadline_seconds"])), + ) _bounded_json_copy(control) try: saved = self.container.create_item(body=control) @@ -840,6 +923,11 @@ def mutator(current): return self._mutate(mutator) def decide(self, *, expected_version, gate_id, choice, actor_user_id, request_id): + if self._read_control().get("schema_version") == 2: + return self.journal_decide( + expected_version=expected_version, gate_id=gate_id, choice=choice, + actor_user_id=actor_user_id, request_id=request_id, + ) if type(expected_version) is not int: raise RuntimeConflict("stale_version", "Workflow runtime version is required.") gate_id = _require_id(gate_id, "gate_id") @@ -894,6 +982,8 @@ def mutator(current): return self._mutate(mutator, attempts=MAX_CAS_RETRIES) def request_cancel(self, *, actor_user_id, request_id): + if self._read_control().get("schema_version") == 2: + return self.journal_request("cancel", actor_user_id=actor_user_id, request_id=request_id) actor_user_id = _require_id(actor_user_id, "actor_user_id") request_id = _require_id(request_id, "request_id") @@ -947,6 +1037,8 @@ def mutator(current): return self._mutate(mutator, attempts=MAX_CAS_RETRIES) def resume(self, *, expected_version, actor_user_id, request_id): + if self._read_control().get("schema_version") == 2: + return self.journal_request("resume", actor_user_id=actor_user_id, request_id=request_id, expected_version=expected_version) if type(expected_version) is not int: raise RuntimeConflict("stale_version", "Workflow runtime version is required.") actor_user_id = _require_id(actor_user_id, "actor_user_id") diff --git a/application/single_app/functions_workflow_structured_execution.py b/application/single_app/functions_workflow_structured_execution.py new file mode 100644 index 000000000..988d25b97 --- /dev/null +++ b/application/single_app/functions_workflow_structured_execution.py @@ -0,0 +1,289 @@ +# functions_workflow_structured_execution.py +"""Schema-2 operation boundaries using paged units rather than a growing control map.""" + +from copy import deepcopy + +from functions_workflow_execution import DurableWorkflowExecution, WorkflowSuspended, execution_fingerprint +from functions_workflow_identity import workflow_execution_id +from functions_workflow_runtime_store import WorkflowRuntimeConflict +from functions_workflow_result_store import load_workflow_node_result, save_workflow_node_result + + +class StructuredWorkflowExecution(DurableWorkflowExecution): + def __init__(self, *args, **kwargs): + kwargs.setdefault("save_result", save_workflow_node_result) + kwargs.setdefault("load_result", load_workflow_node_result) + super().__init__(*args, **kwargs) + self.node = None + self.region_id = self.workflow["flow"]["id"] + + def set_node(self, node, region_id): + self.node = node + self.region_id = region_id + + def check(self): + record = self.lease.check() + if self.store._now().isoformat() >= record["deadline_at"]: + self.store.pause_execution_limit(self.lease.token, "deadline_exceeded") + raise WorkflowSuspended("paused") + return record + + def freeze_reference(self, reference, loader): + key = ["reference", reference["id"]] + row = self.store.journal_read("unit", key) + snapshot = None + if row: + saved = row["payload"] + snapshot = self.load_result( + self.workflow, self.run_id, None, saved["result_ref"], **saved["selectors"], + ) + value = loader(snapshot=snapshot) + if row is None: + root = self.workflow["flow"]["id"] + selectors = { + "node_id": root, "execution_id": workflow_execution_id(self.workflow, self.run_id, root), + "iteration_path": [], "attempt": 1, + } + result_ref = self.save_result(self.workflow, self.run_id, None, value, settings=self.settings, **selectors) + self.store.journal_commit(self.lease.token, "unit", key, { + "state": "completed", "selectors": selectors, "result_ref": result_ref, + }, immutable=True) + return value + + def save_runtime_record(self, record): + node_id = self.workflow["flow"]["id"] + selectors = { + "node_id": node_id, "execution_id": workflow_execution_id(self.workflow, self.run_id, node_id), + "attempt": 1, "iteration_path": [], + } + reference = self.save_result( + self.workflow, self.run_id, None, {"run_record": record}, settings=self.settings, **selectors, + ) + return {"result_ref": reference, "selectors": selectors} + + def execution_id(self): + return workflow_execution_id( + self.workflow, self.run_id, self.node["id"] if self.node else self.workflow["flow"]["id"], + ) + + def _key(self, key): + return [self.execution_id(), key] + + def unit(self, key): + row = self.store.journal_read("unit", self._key(key)) + return deepcopy(row["payload"]) if row else {} + + def selectors(self, *, attempt=None): + task_id = self.node.get("task_id") if self.node else None + unit = self.unit(f"task:{task_id}") if task_id else {} + return { + "execution_id": self.execution_id(), "node_id": self.node["id"] if self.node else self.workflow["flow"]["id"], + "iteration_path": [], "attempt": attempt or unit.get("attempt") or 1, + } + + def _save_payload(self, key, payload): + task_id = self.node.get("task_id") if self.node else None + return self.save_result( + self.workflow, self.run_id, task_id, payload, settings=self.settings, + **self.selectors(attempt=payload.get("attempt")), + ) + + def _saved(self, unit, key): + task_id = self.node.get("task_id") if self.node else None + payload = self.load_result( + self.workflow, self.run_id, task_id, unit["result_ref"], + **{**unit["selectors"]}, + ) + if ( + payload.get("unit_id") != key or payload.get("input_digest") != unit.get("input_digest") + or payload.get("attempt") != unit.get("attempt") + ): + raise WorkflowRuntimeConflict("workflow_checkpoint_invalid") + return payload["value"] + + def _pause(self, key, digest): + if self.node: + self.record_execution(state="paused", reason_code="inputs_changed") + self.store.wait(self.lease.token, state="paused", gate={ + "id": execution_fingerprint([self.execution_id(), key, digest, "pause"]), + "kind": "pause", "unit_id": key, "input_digest": digest, **self.selectors(), + "definition_revision": self.workflow.get("definition_revision"), + "reason": "Saved inputs changed. Cancel this run and start a new one.", "choices": ["cancel"], + }) + raise WorkflowSuspended("paused") + + def _gate(self, key, digest, attempt, kind, reason, inputs=None): + gate_id = execution_fingerprint([self.execution_id(), key, digest, attempt, kind]) + row = self.store.journal_read("decision", ["gate", gate_id]) + choice = "approve" if kind == "approval" else "retry" + if row and all(row["payload"].get(name) == value for name, value in { + "execution_id": self.execution_id(), "attempt": attempt, "input_digest": digest, "choice": choice, + }.items()): + return + state = "waiting_approval" if kind == "approval" else "waiting_recovery" + if self.node and self.node["kind"] == "task": + self.record_execution( + state=state, attempt=attempt, + consumed_inputs=(inputs or {}).get("consumed_inputs") or [], + reference_sources=(inputs or {}).get("references") or [], + ) + self._attempt(attempt, state=state) + self.store.wait(self.lease.token, state=state, gate={ + "id": gate_id, "kind": kind, "unit_id": key, "input_digest": digest, + **self.selectors(attempt=attempt), "reason": reason, + "definition_revision": self.workflow.get("definition_revision"), + "choices": ["approve", "reject"] if kind == "approval" else ["retry", "cancel"], + }) + raise WorkflowSuspended(state) + + def record_execution(self, **fields): + if self.node is None: + return None + previous = self.store.journal_read("execution", self.execution_id()) + if previous and previous["payload"].get("state") in {"succeeded", "completed", "skipped", "failed", "invalid", "incomplete"}: + prior = previous["payload"] + if fields.get("attempt", prior.get("attempt")) == prior.get("attempt") and fields.get("state") == prior.get("state"): + if fields.get("workflow_result", prior.get("workflow_result")) != prior.get("workflow_result"): + raise WorkflowRuntimeConflict("immutable_attempt_conflict") + return previous + payload = previous["payload"] if previous else { + "execution_id": self.execution_id(), "node_id": self.node["id"], "node_kind": self.node["kind"], + "iteration_path": [], "region_id": self.region_id, "attempt": 0, + **({"task_id": self.node["task_id"]} if self.node.get("task_id") else {}), + } + if previous and fields.get("attempt", payload["attempt"]) != payload["attempt"]: + payload = {key: value for key, value in payload.items() if key not in { + "workflow_result", "workflow_validation", "consumed_inputs", "completed_at", "started_at", "reason_code", + }} + return self.store.journal_commit( + self.lease.token, "execution", self.execution_id(), {**payload, **fields}, + updates={"cursor": {"region_id": self.region_id, "node_id": self.node["id"]}}, + ) + + def run_unit(self, key, operation, *, inputs, replay_safe=False, approval=None): + self.check() + digest = execution_fingerprint(inputs) + unit = self.unit(key) + if unit and unit.get("input_digest") != digest: + self._pause(key, digest) + if unit.get("state") == "completed": + result = self._saved(unit, key) + self.check() + return result + execution_row = self.store.journal_read("execution", self.execution_id()) if self.node else None + if execution_row and execution_row["payload"].get("state") == "paused" and unit.get("state") != "completed": + self.record_execution(state="queued", reason_code="") + previous_attempt = int(unit.get("attempt") or 0) + task_operation = self.node is not None and key == f"task:{self.node.get('task_id')}" + if task_operation: + self.record_execution( + state="queued", attempt=previous_attempt + 1, + consumed_inputs=inputs.get("consumed_inputs") or [], + reference_sources=inputs.get("references") or [], + ) + if unit and unit.get("state") in {"running", "failed"} and ( + not unit.get("replay_safe") or previous_attempt >= 3 + ): + self._gate(key, digest, previous_attempt, "recovery", + "Review any external effects before retrying the interrupted task.", inputs) + attempt = previous_attempt + 1 + if approval and approval.get("required") is True: + self._gate(key, digest, attempt, "approval", + str(approval.get("message") or "Review this exact task attempt before execution.")[:1000], inputs) + selectors = self.selectors(attempt=attempt) + unit = { + "state": "running", "attempt": attempt, "input_digest": digest, + "replay_safe": bool(replay_safe), "selectors": selectors, "execution_id": self.execution_id(), + } + if task_operation: + admitted_by_condition = attempt == 1 and self.store.journal_read( + "decision", ["control", self.execution_id()], + ) is not None + self.store.journal_commit( + self.lease.token, "admission", [self.execution_id(), attempt], + {"execution_id": self.execution_id(), "attempt": attempt, "input_digest": digest}, + admission=not admitted_by_condition, immutable=True, + updates={"cursor": {"region_id": self.region_id, "node_id": self.node["id"]}, "phase": self.node["id"]}, + ) + self.record_execution(state="running", attempt=attempt, started_at=self.store._now().isoformat(), + consumed_inputs=inputs.get("consumed_inputs") or []) + self._attempt(attempt, state="running") + self.store.journal_commit(self.lease.token, "unit", self._key(key), unit) + try: + result = operation() + except Exception: + self.check() + self.store.journal_commit(self.lease.token, "unit", self._key(key), {**unit, "state": "failed"}) + if task_operation: + self.record_execution(state="failed", attempt=attempt) + self._attempt(attempt, state="failed") + if not replay_safe: + self._gate(key, digest, attempt, "recovery", "Review any external effects before retrying this failed attempt.", inputs) + raise + self.check() + reference = self._save_payload(key, { + "unit_id": key, "input_digest": digest, "attempt": attempt, "value": result, + }) + self.store.journal_commit(self.lease.token, "unit", self._key(key), { + **unit, "state": "completed", "result_ref": reference, + }) + return result + + def _attempt(self, attempt, **fields): + row = self.store.journal_read("execution", self.execution_id()) + payload = {**(row["payload"] if row else {}), "attempt": attempt, **fields} + previous = self.store.journal_read("attempt", [self.execution_id(), attempt]) + if previous and previous["payload"].get("state") not in {"running", "waiting_output", "pending", "waiting_approval", "waiting_recovery"}: + if previous["payload"].get("workflow_result") != payload.get("workflow_result"): + raise WorkflowRuntimeConflict("immutable_attempt_conflict") + if fields.get("state") not in {previous["payload"]["state"], "waiting_recovery"}: + raise WorkflowRuntimeConflict("immutable_attempt_conflict") + return previous + return self.store.journal_commit( + self.lease.token, "attempt", [self.execution_id(), attempt], payload, + ) + + def finish_node(self, **fields): + row = self.record_execution(**fields, completed_at=self.store._now().isoformat()) + if self.node and fields.get("state") != "skipped": + self._attempt(row["payload"]["attempt"], **{key: value for key, value in fields.items() if key != "attempt"}) + return row + + def snapshot(self, key): + self.check() + unit = self.unit(key) + return self._saved(unit, key) if unit.get("state") == "completed" else None + + def invalidate_task(self, key): + unit = self.unit(key) + if unit: + self.store.journal_commit(self.lease.token, "unit", self._key(key), {**unit, "state": "failed"}) + + def replace_unit_result(self, key, value): + unit = self.unit(key) + if unit.get("state") != "completed": + raise WorkflowRuntimeConflict("workflow_checkpoint_unavailable") + reference = self._save_payload(key, { + "unit_id": key, "input_digest": unit["input_digest"], "attempt": unit["attempt"], "value": value, + }) + self.store.journal_commit(self.lease.token, "unit", self._key(key), {**unit, "result_ref": reference}) + + def wait_for_output(self, key, references): + unit = self.unit(key) + self.record_execution(state="waiting_output", attempt=int(unit.get("attempt") or 1)) + self.store.wait(self.lease.token, state="waiting_output", gate={ + "id": execution_fingerprint([self.execution_id(), key, unit.get("attempt"), "output"]), + "kind": "output", "unit_id": key, "input_digest": unit.get("input_digest", ""), + **self.selectors(), "reason": "Waiting for the submitted output to finish.", + "choices": [], "references": references, + }) + raise WorkflowSuspended("waiting_output") + + def may_recover_analysis_unit(self, task_id): + unit = self.unit(f"task:{task_id}") + attempt = int(unit.get("attempt") or 0) - 1 + gate_id = execution_fingerprint([ + self.execution_id(), f"task:{task_id}", unit.get("input_digest"), attempt, "recovery", + ]) + row = self.store.journal_read("decision", ["gate", gate_id]) + return bool(row and row["payload"].get("choice") == "retry") diff --git a/application/single_app/route_backend_workflows.py b/application/single_app/route_backend_workflows.py index 1a3e17cb3..483097c93 100644 --- a/application/single_app/route_backend_workflows.py +++ b/application/single_app/route_backend_workflows.py @@ -99,6 +99,7 @@ workflow_runtime_status, ) from functions_workflow_runtime_store import RuntimeUnavailable, WorkflowRuntimeConflict +from functions_workflow_execution_history import workflow_execution_history, workflow_execution_result_page from route_backend_agents import ( _build_agent_instruction_api_params, _create_agent_instruction_client, @@ -150,6 +151,8 @@ def _workflow_definition_response(workflow, reader_user_id): def _workflow_task_result_page_response(workflow, run_record, task_id, get_item): + if (run_record or {}).get('definition_version') == 3 or ((run_record or {}).get('runtime') or {}).get('schema_version') == 2: + return jsonify({'error': 'Select an exact execution and attempt for this structured run.'}), 409 run_id = _normalize_identifier((run_record or {}).get('id')) workflow_id = _normalize_identifier((workflow or {}).get('id')) if not workflow_id or not run_id or _normalize_identifier(run_record.get('workflow_id')) != workflow_id: @@ -162,6 +165,8 @@ def _workflow_task_result_page_response(workflow, run_record, task_id, get_item) )): return jsonify({'error': 'Workflow task result not found.'}), 404 summary = item.get('workflow_result') or {} + if summary.get('contract_version') == 'workflow-result-v2': + return jsonify({'error': 'Select an exact execution and attempt for this structured run.'}), 409 result_ref = summary.get('result_ref') if not isinstance(result_ref, dict): return jsonify({'error': 'This task has no durable result. Older runs contain previews only.'}), 409 @@ -309,6 +314,48 @@ def _workflow_runtime_response(workflow_id, run_id, *, group=False, action=None) return jsonify({'error': 'Workflow progress is temporarily unavailable.'}), 503 +def _workflow_execution_history_response(workflow_id, run_id, *, group=False, kind='execution', + execution_id=None, attempt=None): + user_id = get_current_user_id() + try: + if group: + group_id, _ = _resolve_group_workflow_request_group(user_id) + workflow = get_group_workflow(group_id, workflow_id) + run = get_group_workflow_run(group_id, run_id) + else: + workflow = get_personal_workflow(user_id, workflow_id) + run = get_personal_workflow_run(user_id, run_id) + if not workflow or not run or run.get('workflow_id') != workflow_id: + return jsonify({'error': 'Workflow run not found.'}), 404 + if attempt is not None: + offset, limit = int(request.args.get('offset', '0')), int(request.args.get('limit', '2000')) + if offset < 0 or not 1 <= limit <= 65536 or attempt < 1: + raise ValueError('Invalid result page.') + response = workflow_execution_result_page( + workflow, run_id, execution_id, attempt, reader_user_id=user_id, + output=request.args.get('output', 'authoritative'), offset=offset, limit=limit, + ) + else: + response = workflow_execution_history( + workflow, run_id, reader_user_id=user_id, kind=kind, execution_id=execution_id, + cursor=request.args.get('cursor'), limit=int(request.args.get('limit', '50')), + ) + return jsonify(response) + except WorkflowRuntimeConflict as exc: + return jsonify({'error': exc.public_message, 'code': exc.code}), 409 + except (PermissionError, AnalysisResultUnavailable): + return jsonify({'error': 'Current access to this execution or its contributing sources could not be confirmed.'}), 403 + except (LookupError, CosmosResourceNotFoundError): + return jsonify({'error': 'Workflow execution or attempt not found.'}), 404 + except (ValueError, TypeError): + return jsonify({'error': 'Invalid execution, attempt or page request.'}), 400 + except (AzureError, RuntimeUnavailable, WorkflowResultStorageUnavailableError) as exc: + log_event('[WORKFLOW_ROUTES] Execution history read failed', + extra={'workflow_id': workflow_id, 'run_id': run_id, 'error_type': type(exc).__name__}, + level=logging.ERROR) + return jsonify({'error': 'Workflow execution history is temporarily unavailable.'}), 503 + + def _queue_workflow_response(workflow, user_id): data = request.get_json(silent=True) if data is None and not request.data: @@ -903,6 +950,78 @@ def _stream_group_workflow_activity(user_id, group_id, conversation_id='', workf def register_route_backend_workflows(bp): + @bp.route('/api/user/workflows//runs//executions', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + @workflow_user_required + def get_user_workflow_executions(workflow_id, run_id): + return _workflow_execution_history_response(workflow_id, run_id) + + @bp.route('/api/group/workflows//runs//executions', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_group_workspaces') + @enabled_required('allow_group_workflows') + def get_group_workflow_executions(workflow_id, run_id): + return _workflow_execution_history_response(workflow_id, run_id, group=True) + + @bp.route('/api/user/workflows//runs//executions//attempts', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + @workflow_user_required + def get_user_workflow_execution_attempts(workflow_id, run_id, execution_id): + return _workflow_execution_history_response(workflow_id, run_id, execution_id=execution_id, kind='attempt') + + @bp.route('/api/group/workflows//runs//executions//attempts', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_group_workspaces') + @enabled_required('allow_group_workflows') + def get_group_workflow_execution_attempts(workflow_id, run_id, execution_id): + return _workflow_execution_history_response(workflow_id, run_id, group=True, execution_id=execution_id, kind='attempt') + + @bp.route('/api/user/workflows//runs//executions//attempts//result', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + @workflow_user_required + def get_user_workflow_execution_result(workflow_id, run_id, execution_id, attempt): + return _workflow_execution_history_response(workflow_id, run_id, execution_id=execution_id, attempt=attempt) + + @bp.route('/api/group/workflows//runs//executions//attempts//result', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_group_workspaces') + @enabled_required('allow_group_workflows') + def get_group_workflow_execution_result(workflow_id, run_id, execution_id, attempt): + return _workflow_execution_history_response(workflow_id, run_id, group=True, execution_id=execution_id, attempt=attempt) + + @bp.route('/api/user/workflows//runs//runtime/decisions', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('allow_user_workflows') + @workflow_user_required + def get_user_workflow_decisions(workflow_id, run_id): + return _workflow_execution_history_response(workflow_id, run_id, kind='decision') + + @bp.route('/api/group/workflows//runs//runtime/decisions', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_group_workspaces') + @enabled_required('allow_group_workflows') + def get_group_workflow_decisions(workflow_id, run_id): + return _workflow_execution_history_response(workflow_id, run_id, group=True, kind='decision') + @bp.route('/api/user/workflows//runs//runtime', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/static/js/workspace/workspace_workflows.js b/application/single_app/static/js/workspace/workspace_workflows.js index 6ee5f37d9..e3530fc79 100644 --- a/application/single_app/static/js/workspace/workspace_workflows.js +++ b/application/single_app/static/js/workspace/workspace_workflows.js @@ -4028,10 +4028,21 @@ function resetWorkflowForm() { showWorkflowStep(0); } +function workflowNeedsNativeEditor(workflow) { + return Boolean(workflow && ( + (workflow.definition_version !== undefined && workflow.definition_version !== 1) + || workflow.flow !== undefined + )); +} + async function openWorkflowModal(workflow = null) { if (!workflowModal) { return; } + if (workflowNeedsNativeEditor(workflow)) { + showToast("This workflow uses advanced data flow. Open V2 to edit it without losing its configuration. Run and Cancel remain available here.", "warning"); + return; + } if (workflowWorkspaceConfig.scope === "group" && !getWorkflowActiveGroupId()) { showToast("Select a group before managing group workflows.", "warning"); return; diff --git a/application/v2_ui/src/components/workflows/WorkflowConditionEditor.tsx b/application/v2_ui/src/components/workflows/WorkflowConditionEditor.tsx new file mode 100644 index 000000000..1479498d8 --- /dev/null +++ b/application/v2_ui/src/components/workflows/WorkflowConditionEditor.tsx @@ -0,0 +1,418 @@ +// WorkflowConditionEditor.tsx +// Typed input and condition controls; predicates are never evaluated in the browser. + +import { useState } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; +import { GlassButton } from '../ui/primitives'; +import { + analyzeWorkflowFlow, + defaultFlowPredicate, + flowBinding, + flowProducers, + FLOW_ALIAS_PATTERN, + FLOW_COMPARISONS, + FLOW_MAX_PREDICATE_DEPTH, + FLOW_OUTPUT_KINDS, + predicateSummary, + scalarSchemaFields, + type FlowProducer, + type WorkflowFlowBinding, + type WorkflowOperand, + type WorkflowPredicate, + type WorkflowScalar, +} from '../../lib/workflowFlow'; +import type { WorkflowDefinition, WorkflowOutputContract, WorkflowOutputKind } from '../../lib/workflowEditor'; +import { isRecord } from '../../lib/workspaceAuthoring'; + +const inputClass = 'mt-1 w-full min-w-0 rounded-lg border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1 focus:border-accent focus:outline-none'; + +export function WorkflowFlowInputs({ + workflow, + nodeId, + bindings, + onChange, + label = 'Named inputs', + availableIds, +}: { + workflow: WorkflowDefinition; + nodeId: string; + bindings: WorkflowFlowBinding[]; + onChange: (bindings: WorkflowFlowBinding[]) => void; + label?: string; + availableIds?: Set; +}) { + const available = availableIds ?? analyzeWorkflowFlow(workflow).available.get(nodeId) ?? new Set(); + const producers = flowProducers(workflow).filter((producer) => available.has(producer.id)); + const update = (index: number, binding: WorkflowFlowBinding) => + onChange(bindings.map((current, position) => position === index ? binding : current)); + const add = () => { + const producer = producers[0]; + if (!producer) return; + let number = 1; + while (bindings.some((binding) => binding.name === `input${number}`)) number++; + onChange([...bindings, { + ...flowBinding(`input${number}`, producer.id, producer.outputs[0]?.name ?? 'authoritative'), + expected_kind: producer.outputs[0]?.kind ?? 'any', + }]); + }; + return ( +
+ {label} +

+ Only these named final outputs are consumed. A skipped producer never falls back to another task. +

+ {bindings.map((binding, index) => { + const producer = producers.find((item) => item.id === binding.source.node_id); + return ( +
+ + + + + + + onChange(bindings.filter((_, position) => position !== index))}> + Remove input + +
+ ); + })} + = 100} onClick={add} + aria-label={`Add ${label.toLowerCase()} input`}> + Add input + + {!producers.length ?

Add a reachable producer before this node to bind its output.

: null} +
+ ); +} + +function OperandEditor({ + value, + bindings, + producers, + onChange, + label, + inputOnly = false, +}: { + value: WorkflowOperand; + bindings: WorkflowFlowBinding[]; + producers: FlowProducer[]; + onChange: (value: WorkflowOperand) => void; + label: string; + inputOnly?: boolean; +}) { + const mode = 'input' in value ? 'input' : 'literal'; + const binding = 'input' in value ? bindings.find((item) => item.name === value.input) : undefined; + const schema = producers.find((item) => item.id === binding?.source.node_id)?.outputs + .find((item) => item.name === binding?.source.output)?.schema; + const fields = scalarSchemaFields(schema); + if (inputOnly && schema && !fields.some((field) => field.path === '')) { + fields.unshift({ path: '', type: String(schema.type ?? 'value') }); + } + const firstField = (name: string): WorkflowOperand => { + const selected = bindings.find((item) => item.name === name); + const output = producers.find((item) => item.id === selected?.source.node_id)?.outputs + .find((item) => item.name === selected?.source.output); + return { input: name, path: scalarSchemaFields(output?.schema)[0]?.path ?? '' }; + }; + const literalType = 'literal' in value ? value.literal === null ? 'null' : typeof value.literal : 'boolean'; + return ( +
+ {label} + {!inputOnly ? ( + + ) : null} + {'input' in value ? ( + <> + + + {!fields.length ? ( +

Declare typed decision fields in the producer's JSON output contract first.

+ ) : null} + + ) : ( + <> + + {typeof value.literal === 'boolean' ? ( + + ) : typeof value.literal === 'number' ? ( + + ) : typeof value.literal === 'string' ? ( + + ) :

Null is different from a missing field. Use Exists to handle absence.

} + + )} +
+ ); +} + +export function WorkflowConditionEditor({ + value, + bindings, + workflow, + onChange, + label = 'Condition', + depth = 1, +}: { + value: WorkflowPredicate; + bindings: WorkflowFlowBinding[]; + workflow: WorkflowDefinition; + onChange: (condition: WorkflowPredicate) => void; + label?: string; + depth?: number; +}) { + const producers = flowProducers(workflow); + const operation = (op: string) => { + const left: WorkflowOperand = 'left' in value ? value.left : value.op === 'exists' + ? value.value : { input: bindings[0]?.name ?? '', path: '' }; + if (op === 'all' || op === 'any') { + onChange({ op, conditions: value.op === 'all' || value.op === 'any' ? value.conditions : [value] }); + } else if (op === 'not') { + onChange({ op, condition: value }); + } else if (op === 'exists') { + onChange({ op, value: 'input' in left ? left : { input: bindings[0]?.name ?? '', path: '' } }); + } else { + const comparison = FLOW_COMPARISONS.find((item) => item === op); + if (comparison) onChange({ op: comparison, left, right: 'right' in value ? value.right : { literal: true } }); + } + }; + return ( +
+ + {value.op === 'all' || value.op === 'any' ? ( +
+ {value.conditions.map((condition, index) => ( +
+ onChange({ ...value, conditions: value.conditions.map((item, position) => position === index ? next : item) })} /> + onChange({ ...value, conditions: value.conditions.filter((_, position) => position !== index) })} + aria-label={`Remove ${label} rule ${index + 1}`}> Remove rule +
+ ))} + = FLOW_MAX_PREDICATE_DEPTH} + onClick={() => onChange({ ...value, conditions: [...value.conditions, defaultFlowPredicate(bindings[0]?.name)] })} + aria-label={`Add ${label} rule`}> Add condition +
+ ) : value.op === 'not' ? ( + onChange({ ...value, condition })} /> + ) : value.op === 'exists' ? ( + onChange({ ...value, value: field })} /> + ) : 'left' in value ? ( +
+ onChange({ ...value, left })} /> + onChange({ ...value, right })} /> +
+ ) : null} + {depth === 1 ? ( + <> +

{predicateSummary(value)}

+

+ Evaluated by the server from validated data. Missing values need an Exists guard; null, false, and zero are distinct values. +

+ + ) : null} +
+ ); +} + +export function WorkflowDecisionFields({ + contract, + onChange, +}: { + contract: WorkflowOutputContract; + onChange: (contract: WorkflowOutputContract) => void; +}) { + const [name, setName] = useState(''); + const [type, setType] = useState('boolean'); + const [enumText, setEnumText] = useState(''); + const schema = contract.schema ?? { type: 'object' }; + if (contract.kind !== 'json' || (schema.type !== undefined && schema.type !== 'object')) return null; + const properties = isRecord(schema.properties) ? schema.properties : {}; + const required = Array.isArray(schema.required) ? schema.required.filter((item): item is string => typeof item === 'string') : []; + const enumValues = enumText.split('\n').map((item) => item.trim()).filter(Boolean); + const validName = FLOW_ALIAS_PATTERN.test(name) && !Object.hasOwn(properties, name); + const validEnum = type !== 'enum' || (enumValues.length > 0 && new Set(enumValues).size === enumValues.length); + return ( +
+ Structured decision fields +

+ Declare Boolean, numeric, or enum fields for If/else and Run when. The task must return them inside its final JSON object. +

+ {Object.entries(properties).map(([fieldName, field]) => ( +
+ {fieldName} ({isRecord(field) ? String(field.type ?? 'custom schema') : 'custom schema'}) + + { + const next = { ...properties }; + delete next[fieldName]; + onChange({ ...contract, schema: { ...schema, properties: next, required: required.filter((item) => item !== fieldName) } }); + }}> +
+ ))} +
+ + +
+ {type === 'enum' ? ( +