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/ {predicateSummary(value)}
+ Evaluated by the server from validated data. Missing values need an Exists guard; null, false, and zero are distinct values.
+
No output contract is configured. The backend treats this as any @@ -1045,6 +1091,72 @@ function TaskCard({ ); } +function TaskPublicationFields({ task, onChange }: { task: WorkflowTask; onChange: (task: WorkflowTask) => void }) { + const publication = task.publication; + const update = (value: WorkflowPublication) => onChange({ ...task, publication: value }); + return ( +
This workflow uses definition version {draft.definition_version}. V2 can read it but cannot safely save it without downgrading fields from a newer editor. + {unsupportedFlow ? ` ${unsupportedFlow}` : ''}
Task IDs stay stable while you edit, so explicit input bindings do not change meaning.
Maximum task count reached for this scope.
) : null} - {draft.tasks.map((task, index) => ( + {draft.definition_version === 3 ? ( ++ {label}: {children} +
+ ); +} + +function ConsumedInputs({ inputs }: { inputs?: WorkflowConsumedInput[] }) { + if (!inputs?.length) { + return null; + } + return ( +Consumed producer refs
++ V3 output inspection always reads the exact execution attempt's authoritative output. +
+ {error ?{error}
: null} + {page ? ( +
+ {page.content}
+
+ ) : null}
+ {page ? (
+ + Offset {page.offset ?? 0} + {page.total_bytes !== undefined ? ` of ${page.total_bytes} bytes` : ''} + {page.sha256 ? ` · sha256 ${page.sha256}` : ''} +
+ ) : null} +Loading execution attempts...
: null} + {page.error ?{page.error}
: null} + {!page.loading && !page.error && !page.items.length ?No attempts were recorded for this execution.
: null} +No result was committed for this attempt.
} +{page.error}
: null} + {page.loading && !page.items.length ?Loading runtime decisions…
: null} + {!page.loading && !page.error && !page.items.length ? ( +No runtime decisions were recorded for this run.
+ ) : null} + {page.items.length ? ( ++ Structured runs use node, execution, and attempt identities. Output excerpts are loaded per attempt so large results are not rendered automatically. +
+Loading execution history...
: null} + {page.error ?{page.error}
: null} + {!page.loading && !page.error && !page.items.length ? ( +No node executions were recorded for this run.
+ ) : null} + {page.items.length ? ( +Loading runs…
; } if (error) { @@ -341,6 +343,9 @@ export function WorkflowRunHistory({ const expanded = expandedRunId === runId; const status = String(run.status ?? 'unknown'); const validation = validationSummary(run.workflow_validation); + const definitionVersion = run.definition_version; + const isStructuredRun = definitionVersion === 3; + const unsupportedDefinitionVersion = definitionVersion !== undefined && ![1, 2, 3].includes(definitionVersion); return (+ This run uses workflow definition v{String(definitionVersion)}, which this inspector does not support yet. +
+ ) : ( + <> ++ This runtime memory schema is not supported by this inspector yet. +
+ ); + } + if (structuredRun) { + const counters = [ + ['unit_count', 'Checkpoint units'], + ['completed_unit_count', 'Completed checkpoint units'], + ['execution_count', 'Execution admissions'], + ['decision_count', 'Saved decisions'], + ].flatMap(([key, label]) => { + const value = memory?.[key]; + return typeof value === 'number' && Number.isFinite(value) ? [{ label, value }] : []; + }); + return ( +Exact execution, attempt, and decision histories use the paged views below. Run memory is not approval authority.
+ {counters.map((item) =>{item.label}: {item.value}
)} +No run memory has been recorded yet.
; @@ -81,8 +149,15 @@ function RuntimeMemoryDetails({ memory }: { memory?: WorkflowRuntimeMemory }) { return (+ Detailed V3 execution, attempt, and decision histories load from the paged run-inspection APIs below. +
+ ) : null}Checkpoint units and attempts
{units.length ? ( @@ -136,12 +211,14 @@ export function WorkflowRuntimePanel({ workflowId, runId, durable, + structuredRun = false, onRuntimeChanged, }: { scope: WorkflowScope; workflowId: string; runId: string; durable: boolean; + structuredRun?: boolean; onRuntimeChanged?: () => void; }) { const scopeKey = workflowScopeKey(scope); @@ -152,6 +229,7 @@ export function WorkflowRuntimePanel({ const [error, setError] = useState(''); const [action, setAction] = useState{progressLabel}
: null} + {structuredRun && runtime?.limits ? ( ++ {runtime.limits.admitted_count} of {runtime.limits.max_executions} execution admissions used. + {' '}Deadline: {formatTimestamp(runtime.limits.deadline_at)} (including waits). +
+ ) : null} {error ?{error}
: null} {runtime && !canDecide ? (You can view this runtime, but you do not have permission to approve, reject, retry, resume or cancel it.
) : null} - {gate ? ( + {unsupportedRuntimeSchema ? ( ++ This runtime schema is not supported by this inspector yet. +
+ ) : null} + {gate && !unsupportedRuntimeSchema ? ({gateReference(gate)}
: null} {gate.reason ?{gate.reason}
: null} {gate.input_digest ?Input digest: {gate.input_digest}
: null} {gate.kind === 'output' ? ( @@ -401,7 +521,12 @@ export function WorkflowRuntimePanel({ {canMutate && gate.kind === 'recovery' ? (- The runtime will keep the same run id and record this decision against the current recovery gate. + The runtime will keep the same run id and bind this decision to the recovery gate you opened.
+ {retryTarget && gateReference(retryTarget.gate) ? ( +{gateReference(retryTarget.gate)}
+ ) : null}This flow cannot be edited by this version of the List editor.
; + } + const flow = workflow.flow; + const regions = flowRegions(flow); + const limits = isRecord(workflow.limits) ? workflow.limits : DEFAULT_FLOW_LIMITS; + const setFlow = (next: WorkflowFlowRegion, tasks = workflow.tasks) => onChange({ ...workflow, flow: next, tasks }); + const setNode = (regionId: string, next: WorkflowFlowNode) => + setFlow(updateFlowRegion(flow, regionId, (region) => ({ + ...region, nodes: region.nodes.map((node) => node.id === next.id ? next : node), + }))); + const add = (regionId: string, kind: WorkflowFlowNode['kind']) => { + const task = { ...createWorkflowTask(workflow.tasks.length), inputs: [], reference_ids: workflow.reference_inputs.map((reference) => reference.id) }; + const id = kind === 'task' ? task.id : `${kind}-${task.id}`; + let node: WorkflowFlowNode; + if (kind === 'task') node = { id, kind, task_id: task.id }; + else if (kind === 'route') node = { id, kind, inputs: [], condition: defaultFlowPredicate(), target: { node_id: '' } }; + else node = { + id, kind, inputs: [], condition: defaultFlowPredicate(), + then: { id: `then-${task.id}`, nodes: [] }, else: { id: `else-${task.id}`, nodes: [] }, + join: { id: `join-${task.id}`, exports: [] }, + }; + setFlow(updateFlowRegion(flow, regionId, (region) => ({ ...region, nodes: [...region.nodes, node] })), + kind === 'task' ? [...workflow.tasks, task] : workflow.tasks); + }; + const move = (regionId: string, nodeId: string, direction: -1 | 1) => + setFlow(updateFlowRegion(flow, regionId, (region) => { + const index = region.nodes.findIndex((node) => node.id === nodeId); + const nodes = [...region.nodes]; + [nodes[index], nodes[index + direction]] = [nodes[index + direction], nodes[index]]; + return { ...region, nodes }; + })); + const moveToRegion = (regionId: string, targetId: string, node: WorkflowFlowNode) => { + const removed = updateFlowRegion(flow, regionId, (region) => ({ ...region, nodes: region.nodes.filter((item) => item.id !== node.id) })); + setFlow(updateFlowRegion(removed, targetId, (region) => ({ ...region, nodes: [...region.nodes, node] }))); + }; + const renderRegion = (region: WorkflowFlowRegion, label: string, depth: number): ReactNode => ( + + ); + return ( +List order inside each region is the executable flow. Reordering never retargets a saved input.
+Required final outputs must exist on every selected path. Leave this list empty only when the workflow does not promise a final deliverable.
+ {removing ? ( +