diff --git a/application/single_app/config.py b/application/single_app/config.py
index 0fa86a0ad..6d17b5021 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.120"
+VERSION = "0.261.121"
IS_DEVELOPMENT = is_development_env_enabled()
SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
diff --git a/application/single_app/functions_workflow_execution_history.py b/application/single_app/functions_workflow_execution_history.py
index f2291eaee..901af0ece 100644
--- a/application/single_app/functions_workflow_execution_history.py
+++ b/application/single_app/functions_workflow_execution_history.py
@@ -1,8 +1,11 @@
# 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_analysis_access import AnalysisResultUnavailable, authorize_analysis_sources, build_analysis_access
+from functions_workflow_flow import compile_workflow_flow
+from functions_workflow_identity import normalize_workflow_iteration_path, workflow_execution_id, workflow_node_identity
+from functions_workflow_inspection import authorize_workflow_flow_sources
+from functions_workflow_journal import public_workflow_journal_entry
from functions_workflow_limits import WORKFLOW_MAX_EXECUTION_ADMISSIONS
from functions_workflow_node_results import (
WorkflowLineageAuthorization, authorize_workflow_node_result_read, load_node_result, result_selectors,
@@ -41,12 +44,50 @@ def authorize_execution_payload(workflow, run_id, payload, *, reader_user_id, au
def workflow_execution_history(workflow, run_id, *, reader_user_id, kind="execution", execution_id=None,
- cursor=None, limit=50, authorization=None):
+ cursor=None, limit=50, authorization=None, node_id=None, iteration_path=None):
+ exact = node_id is not None or iteration_path is not None
+ if type(limit) is not int or not 1 <= limit <= 100:
+ raise ValueError("Execution history pages require a limit between 1 and 100.")
+ if exact and (
+ not isinstance(node_id, str) or not node_id or not isinstance(iteration_path, list)
+ or kind != "execution" or execution_id is not None or cursor is not None
+ ):
+ raise ValueError("An exact execution requires only a paired node and iteration path.")
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()
authorization = authorization or WorkflowLineageAuthorization(workflow, run_id, reader_user_id=reader_user_id, store=store)
+ if exact:
+ compiled = compile_workflow_flow(workflow)
+ entry = compiled["nodes"].get(node_id)
+ if node_id == compiled["flow"]["id"]:
+ entry = {"node": {"id": node_id, "kind": "root"}, "region_id": node_id}
+ if entry is None:
+ raise ValueError("The selected structural node has no execution identity.")
+ path = normalize_workflow_iteration_path(iteration_path)
+ selected_id = workflow_execution_id(workflow, run_id, node_id, path)
+ row = store.journal_read("execution", selected_id)
+ if row is None:
+ authorize_workflow_flow_sources(workflow, reader_user_id=reader_user_id)
+ # No payload exists to supply receipts: prove membership from the
+ # frozen items and sealed admissions, never from the requested path alone.
+ authorization.walk([("path", {
+ "node_id": node_id, "execution_id": selected_id, "iteration_path": path,
+ }, None)])
+ if authorization.access()["source_snapshot_changed"]:
+ raise AnalysisResultUnavailable("analysis_source_snapshot_changed")
+ return {"executions": [], "next_cursor": None, "total_count": 0}
+ payload = row["payload"]
+ expected = {
+ "execution_id": selected_id, "node_id": node_id, "iteration_path": path,
+ "node_kind": entry["node"]["kind"], "region_id": entry["region_id"],
+ "task_id": entry["node"].get("task_id"),
+ }
+ if any(payload.get(name) != value for name, value in expected.items()):
+ raise ValueError("The execution payload does not match the frozen node and path.")
+ authorize_execution_payload(workflow, run_id, payload, reader_user_id=reader_user_id, authorization=authorization)
+ return {"executions": [public_workflow_journal_entry(row, "execution")], "next_cursor": None, "total_count": 1}
if execution_id:
execution = store.journal_read("execution", execution_id)
if execution is None:
diff --git a/application/single_app/functions_workflow_inspection.py b/application/single_app/functions_workflow_inspection.py
new file mode 100644
index 000000000..43b340798
--- /dev/null
+++ b/application/single_app/functions_workflow_inspection.py
@@ -0,0 +1,528 @@
+# functions_workflow_inspection.py
+"""Read-only, compiler-derived workflow topology and bounded authored details."""
+
+import json
+import math
+import re
+
+from functions_analysis_access import (
+ AnalysisResultUnavailable, authorize_analysis_sources, resolve_analysis_source_manifest,
+)
+from functions_document_analysis_results import normalize_analysis_options
+from functions_workflow_bindings import WorkflowInputError, authorize_workflow_reference
+from functions_workflow_definitions import (
+ WorkflowDefinitionConflict, WorkflowDefinitionError, normalize_workflow_definition,
+ normalize_workflow_references, workflow_definition_revision,
+)
+from functions_workflow_flow import compile_workflow_flow, normalize_flow_bindings
+from functions_workflow_identity import canonical_digest
+from functions_workflow_loop_history import _next_cursor, _page_position
+from functions_workflow_loop_inputs import _default_authorize_scope
+from functions_workflow_runtime_store import WorkflowRuntimeConflict, workflow_runtime_store
+
+
+FLOW_PROJECTION_VERSION = 1
+FLOW_DETAIL_MAX_BYTES = 240 * 1024
+FLOW_DETAIL_SECTIONS = frozenset({"configuration", "inputs", "condition", "outputs", "state", "selection"})
+_PREVIEW_PRESERVED_FIELDS = frozenset({
+ "metadata", "alerts", "alert_settings", "document_actions", "publication", "publication_options",
+})
+_DIGEST = re.compile(r"[a-f0-9]{64}\Z")
+
+
+class WorkflowFlowUnsupported(ValueError):
+ public_message = "Flow inspection requires a supported version-3 structured definition and, for runs, a frozen schema-2 snapshot."
+ code = "workflow_flow_unsupported"
+
+
+class WorkflowFlowDetailTooLarge(ValueError):
+ public_message = "This complete definition detail exceeds the inline inspection limit. No shortened detail was returned."
+ code = "workflow_flow_detail_limit"
+
+
+def _text(value, maximum=256):
+ if not isinstance(value, str) or len(value) > maximum:
+ raise WorkflowDefinitionError("A workflow inspection field exceeds its supported text bound.")
+ return value
+
+
+def _fields(value, *, text=(), boolean=(), numeric=()):
+ """Nested configuration is projected by type and field, never copied wholesale."""
+ if not isinstance(value, dict):
+ raise WorkflowDefinitionError("A workflow configuration section must be an object.")
+ result = {}
+ for name in text:
+ if name in value:
+ result[name] = _text(value[name])
+ for name in boolean:
+ if name in value:
+ if type(value[name]) is not bool:
+ raise WorkflowDefinitionError("A workflow configuration flag must be boolean.")
+ result[name] = value[name]
+ for name in numeric:
+ if name in value:
+ number = value[name]
+ if number is not None and not (
+ type(number) is int or type(number) is float and math.isfinite(number)
+ ):
+ raise WorkflowDefinitionError("A workflow configuration number must be finite.")
+ result[name] = number
+ return result
+
+
+def _runner(value):
+ result = _fields(value, text=("type", "model_endpoint_id", "model_id", "model_provider"))
+ if any("://" in item for item in result.values()):
+ raise WorkflowDefinitionError("Workflow runners must use configured identifiers, not provider URLs.")
+ if "selected_agent" in value and value["selected_agent"] is not None:
+ result["selected_agent"] = _fields(
+ value["selected_agent"], text=("id", "name", "display_name", "group_id"),
+ boolean=("is_global", "is_group"),
+ )
+ return result
+
+
+def _action(value):
+ result = _fields(
+ value, text=("type", "doc_scope", "analysis_mode", "target_mode", "window_unit", "loop_id"),
+ numeric=("window_size", "window_percent", "max_retries_per_window", "recent_window_minutes"),
+ )
+ if "analysis_options" in value or "transformation_spec" in value:
+ result["analysis_options"] = normalize_analysis_options(
+ value.get("analysis_options"), value.get("transformation_spec"),
+ )
+ return result
+
+
+def _row(label, value):
+ return {"label": label, "value": value}
+
+
+def _label(value, fallback):
+ return value[:120] if isinstance(value, str) and value.strip() else fallback
+
+
+def _detail_name(value):
+ if not isinstance(value, str):
+ raise WorkflowDefinitionError("An authored workflow or task name must be text.")
+ if len(value) > FLOW_DETAIL_MAX_BYTES:
+ raise WorkflowFlowDetailTooLarge()
+ return value
+
+
+def _task_label(task):
+ name = _label(task.get("name"), task["id"])
+ if task.get("publication") is not None:
+ return f"Publish: {name}"
+ if isinstance(task.get("document_action"), dict) and task["document_action"].get("type") == "analyze":
+ return f"Analyze: {name}"
+ return name
+
+
+def _inputs(node, tasks):
+ if node["kind"] == "task":
+ return tasks[node["task_id"]]["inputs"]
+ if node["kind"] == "repeat_until":
+ return normalize_flow_bindings([{
+ "name": slot["name"], "source": slot["initial"], "required": True,
+ "expected_kind": slot["output_contract"]["kind"],
+ "allow_partial": slot["output_contract"]["allow_partial"],
+ } for slot in node["state"]])
+ return node.get("inputs", [])
+
+
+def _outputs(node, tasks):
+ kind = node["kind"]
+ if kind == "task":
+ contract = tasks[node["task_id"]].get("output_contract")
+ return [_row("Output contract", contract)] if contract is not None else []
+ if kind == "collect":
+ return [_row("Output contract", node["output_contract"])]
+ if kind == "for_each":
+ return [_row(binding["name"], binding) for binding in node["body"]["outputs"]]
+ return [_row(item["name"], item) for item in node.get("exports", node.get("outputs", []))]
+
+
+def _topology(compiled):
+ tasks = {task["id"]: task for task in compiled["tasks"]}
+ nodes, edges = [], []
+
+ def edge(source, target, kind, label):
+ edges.append({
+ "id": f"flow-edge:{len(edges)}", "source": source, "target": target, "kind": kind, "label": label,
+ })
+
+ def boundary(region_id):
+ parent_id = compiled["regions"][region_id]["parent"]
+ if parent_id is None:
+ return region_id, "complete", "Workflow complete"
+ parent = compiled["nodes"][parent_id]["node"]
+ if parent["kind"] == "if":
+ return parent["join"]["id"], "join", "Branch complete"
+ if parent["kind"] == "repeat_until":
+ return parent_id, "repeat", f"After body: evaluate Until; false repeats within batch of {parent['max_iterations']}"
+ return parent_id, "repeat", "Item complete; next frozen item if available"
+
+ def following(node, region_id):
+ target = compiled["successor"].get(node["id"])
+ return (target, "sequence", "Next") if target else boundary(region_id)
+
+ def project_node(node, region_id, order):
+ kind = node["kind"]
+ children = (
+ [node["then"]["id"], node["else"]["id"]] if kind == "if"
+ else [node["body"]["id"]] if kind in {"for_each", "repeat_until"} else []
+ )
+ result = {
+ "id": node["id"], "kind": kind,
+ "label": _task_label(tasks[node["task_id"]]) if kind == "task" else {
+ "if": "If", "join": "Join", "route": "Route", "for_each": "For each",
+ "repeat_until": "Repeat until", "collect": "Collect",
+ }[kind],
+ "parent_id": region_id, "region_id": region_id, "order": order,
+ "loop_ids": compiled["node_loop_ids"][node["id"]], "child_region_ids": children,
+ "inputs_count": len(_inputs(node, tasks)), "outputs_count": len(_outputs(node, tasks)),
+ "has_condition": any(key in node for key in ("condition", "run_when", "until")),
+ }
+ for name in ("task_id", "max_items", "max_iterations"):
+ if name in node:
+ result[name] = node[name]
+ nodes.append(result)
+
+ def region(current, label, order=0):
+ identifier = current["id"]
+ parent_id = compiled["regions"][identifier]["parent"]
+ nodes.append({
+ "id": identifier, "kind": "region", "label": label, "parent_id": parent_id,
+ "region_id": identifier, "order": order, "loop_ids": compiled["node_loop_ids"][identifier],
+ "child_region_ids": [], "inputs_count": 0, "outputs_count": len(current.get("outputs", [])),
+ "has_condition": False,
+ })
+ if parent_id is not None:
+ if current["nodes"]:
+ edge(identifier, current["nodes"][0]["id"], "sequence", "Enter region")
+ else:
+ target, kind, completion = boundary(identifier)
+ edge(identifier, target, kind, f"Empty region: {completion}")
+ position = 0
+ for node in current["nodes"]:
+ project_node(node, identifier, position)
+ position += 1
+ target, connection, completion = following(node, identifier)
+ kind = node["kind"]
+ if kind == "if":
+ join = compiled["nodes"][node["join"]["id"]]["node"]
+ project_node(join, identifier, position)
+ position += 1
+ edge(node["id"], node["then"]["id"], "then", "True")
+ edge(node["id"], node["else"]["id"], "else", "False")
+ region(node["then"], "Then")
+ region(node["else"], "Else", 1)
+ edge(join["id"], target, connection, completion)
+ elif kind in {"for_each", "repeat_until"}:
+ repeat = kind == "repeat_until"
+ edge(node["id"], node["body"]["id"], "body", "Body before Until" if repeat else "Frozen item body")
+ region(node["body"], "Repeat body" if repeat else "For each body")
+ edge(node["id"], target, "complete", "After body: Until true" if repeat else "All frozen items complete")
+ elif kind == "route":
+ destination = node["target"]
+ if "node_id" in destination:
+ edge(node["id"], destination["node_id"], "route", "True: route forward")
+ else:
+ exit_target, _, _ = boundary(destination["exit_region_id"])
+ edge(node["id"], exit_target, "exit", "True: exit branch region")
+ edge(node["id"], target, connection, f"False: {completion}")
+ else:
+ if "run_when" in node:
+ completion = f"Run when true; otherwise intentionally skip. {completion}"
+ edge(node["id"], target, connection, completion)
+
+ region(compiled["flow"], "Workflow")
+ return nodes, edges
+
+
+def _configuration(workflow, node, tasks, compiled):
+ kind = node["kind"]
+ if kind == "task":
+ task = tasks[node["task_id"]]
+ rows = [_row("Task", task["id"]), _row("Task name", _detail_name(task.get("name", ""))),
+ _row("Instructions", task["instructions"]),
+ _row("Runner", _runner(task.get("runner", {"type": "inherit"})))]
+ if task.get("output_contract") is not None:
+ rows.append(_row("Output contract", task["output_contract"]))
+ if "input_processing" in task:
+ rows.append(_row("Input processing", task["input_processing"]))
+ if task.get("approval") is not None:
+ rows.append(_row("Approval", {
+ "required": task["approval"].get("required", False),
+ "message": _text(task["approval"].get("message", ""), 1000),
+ }))
+ if task.get("document_action") is not None:
+ rows.append(_row("Document action", _action(task["document_action"])))
+ if task.get("publication") is not None:
+ rows.append(_row("Publication", _fields(
+ task["publication"],
+ text=("source_kind", "artifact_format", "workspace_scope", "group_id", "public_workspace_id", "completion_policy"),
+ )))
+ return rows
+ if kind == "region":
+ rows = [_row("Region", node["id"])]
+ if node["id"] == compiled["flow"]["id"]:
+ rows.extend([
+ _row("Workflow name", _detail_name(workflow.get("name", ""))),
+ _row("Run limits", compiled["limits"]),
+ _row("Workflow runner", _runner({
+ "type": workflow.get("runner_type", "model"),
+ **{key: workflow[key] for key in ("selected_agent", "model_endpoint_id", "model_id", "model_provider")
+ if key in workflow},
+ })),
+ ])
+ return rows
+ if kind == "for_each":
+ return [_row("Maximum items", node["max_items"]), _row("Item key", node["item_key"]),
+ _row("Execution", "Serial frozen-item body template")]
+ if kind == "repeat_until":
+ return [_row("Maximum iterations per automatic batch", node["max_iterations"]),
+ _row("Condition timing", "Until is evaluated after the body and next-state validation."),
+ _row("Batch exhaustion", "Pauses for an explicit authorized continuation; lifetime limits never reset.")]
+ if kind == "route":
+ return [_row("True target", node["target"]), _row("False path", "Continue to the next sibling or region boundary.")]
+ if kind == "collect":
+ return [_row("Collection source", node["source"]), _row("Output contract", node["output_contract"])]
+ return [_row("Control", "Explicit branch join" if kind == "join" else "Typed conditional branch")]
+
+
+def _references(workflow, task=None):
+ references = normalize_workflow_references(
+ workflow.get("reference_inputs", []), user_id=workflow["user_id"], group_id=workflow.get("group_id", ""),
+ )
+ selected = task.get("reference_ids") if task is not None else None
+ if selected is not None:
+ if not isinstance(selected, list) or any(not isinstance(value, str) for value in selected):
+ raise WorkflowDefinitionError("Selected shared references must be document identifiers.")
+ references = [reference for reference in references if reference["id"] in selected]
+ return references
+
+
+def _selection(workflow, node, tasks):
+ if node["kind"] == "for_each":
+ iterable = node["iterable"]
+ rows = [_row("Iterable", {name: iterable[name] for name in ("kind", "name", "filters", "content", "selection")
+ if name in iterable})]
+ rows.extend(_row("Document", value) for value in iterable.get("documents", []))
+ rows.extend(_row("Workspace", value) for value in iterable.get("scopes", []))
+ return rows
+ if node["kind"] not in {"task", "region"}:
+ return []
+ if node["kind"] == "region" and node["id"] != workflow["flow"]["id"]:
+ return []
+ task = tasks[node["task_id"]] if node["kind"] == "task" else None
+ rows = [_row(reference["name"], reference) for reference in _references(workflow, task)]
+ action = (task if task is not None else workflow).get("document_action") or {}
+ if not isinstance(action, dict):
+ raise WorkflowDefinitionError("Document selection configuration must be an object.")
+ for name, label in (
+ ("document_ids", "Selected document"), ("right_document_ids", "Comparison document"),
+ ("active_group_ids", "Source group"), ("active_public_workspace_id", "Source public workspace"),
+ ):
+ values = action.get(name, [])
+ if not isinstance(values, list) or len(values) > 5000:
+ raise WorkflowDefinitionError("Document selections must be bounded identifier lists.")
+ rows.extend(_row(label, {name: _text(value)}) for value in values)
+ if action.get("left_document_id"):
+ rows.append(_row("Comparison source", {"document_id": _text(action["left_document_id"])}))
+ return rows
+
+
+def _detail_rows(workflow, compiled, node_id, section):
+ if node_id in compiled["regions"]:
+ node = {"kind": "region", **compiled["regions"][node_id]["region"]}
+ elif node_id in compiled["nodes"]:
+ node = compiled["nodes"][node_id]["node"]
+ else:
+ raise LookupError("This structural node is not in the selected definition.")
+ tasks = {task["id"]: task for task in compiled["tasks"]}
+ if section == "configuration":
+ return _configuration(workflow, node, tasks, compiled)
+ if section == "inputs":
+ return [_row(binding["name"], binding) for binding in _inputs(node, tasks)]
+ if section == "condition":
+ return [_row({"condition": "Condition", "run_when": "Run when", "until": "Until (after body)"}[name], node[name])
+ for name in ("condition", "run_when", "until") if name in node]
+ if section == "outputs":
+ return _outputs(node, tasks)
+ if section == "state":
+ return [_row(slot["name"], slot) for slot in node.get("state", [])]
+ return _selection(workflow, node, tasks)
+
+
+def _json_size(value):
+ try:
+ return len(json.dumps(value, ensure_ascii=True, allow_nan=False).encode("ascii"))
+ except (ValueError, TypeError, RecursionError) as exc:
+ raise WorkflowDefinitionError("Workflow inspection requires bounded finite JSON.") from exc
+
+
+def workflow_flow_inspection(workflow, *, source_kind="saved", run_id=None, snapshot_sha256=None,
+ node_id=None, section=None, revision=None, cursor=None, limit=50):
+ """Pure projection. Callers must first authorize this exact definition source."""
+ if not isinstance(workflow, dict) or type(workflow.get("definition_version")) is not int or workflow["definition_version"] != 3:
+ raise WorkflowFlowUnsupported()
+ if source_kind not in {"saved", "draft", "run"} or (source_kind == "run") != (run_id is not None):
+ raise ValueError("Invalid definition source.")
+ if type(limit) is not int or not 1 <= limit <= 100:
+ raise ValueError("Inspection pages require a limit between 1 and 100.")
+ if (node_id is None) != (section is None) or section is not None and section not in FLOW_DETAIL_SECTIONS:
+ raise ValueError("Select an exact structural node and supported detail section.")
+ if cursor is not None and (node_id is None or not isinstance(cursor, str) or not cursor):
+ raise ValueError("A detail cursor requires a structural node and section.")
+ definition_revision = workflow_definition_revision(workflow)
+ if source_kind == "draft":
+ definition_revision = f"DRAFT:{definition_revision}"
+ if (node_id is not None or revision is not None) and revision != definition_revision:
+ raise WorkflowDefinitionConflict("This definition changed. Refresh Flow before inspecting its details.")
+ group_id = workflow.get("group_id")
+ source = {
+ "kind": source_kind, "scope_type": "group" if group_id else "personal",
+ "scope_id": _text(group_id or workflow.get("user_id")),
+ "workflow_id": _text(workflow["id"]) if workflow.get("id") else None,
+ "run_id": _text(run_id) if run_id is not None else None, "definition_revision": definition_revision,
+ }
+ if snapshot_sha256 is not None:
+ if source_kind != "run" or not isinstance(snapshot_sha256, str) or not _DIGEST.fullmatch(snapshot_sha256):
+ raise ValueError("Invalid frozen definition digest.")
+ source["snapshot_sha256"] = snapshot_sha256
+ compiled = compile_workflow_flow(workflow)
+ if node_id is None:
+ nodes, edges = _topology(compiled)
+ return {
+ "projection_version": FLOW_PROJECTION_VERSION, "definition_version": 3, "source": source,
+ "name": _label(workflow.get("name"), "Workflow"), "root_region_id": compiled["flow"]["id"],
+ "nodes": nodes, "edges": edges, "limits": compiled["limits"],
+ }
+ if not isinstance(node_id, str):
+ raise ValueError("Select a canonical structural node id.")
+ scope = {"projection_version": FLOW_PROJECTION_VERSION, "source": source, "node_id": node_id, "section": section}
+ offset = _page_position(scope, cursor, limit)
+ rows = _detail_rows(workflow, compiled, node_id, section)
+ if offset > len(rows) or cursor and offset == len(rows):
+ raise ValueError("The detail cursor exceeds this definition section.")
+ result = {**scope, "items": [], "total_count": len(rows), "next_cursor": None}
+ used = _json_size(result) + 1024
+ for row in rows[offset:offset + limit]:
+ size = _json_size(row) + 1
+ if size + _json_size({**scope, "total_count": len(rows), "next_cursor": None, "items": []}) + 1024 > FLOW_DETAIL_MAX_BYTES:
+ raise WorkflowFlowDetailTooLarge()
+ if used + size > FLOW_DETAIL_MAX_BYTES:
+ break
+ result["items"].append(row)
+ used += size
+ result["next_cursor"] = _next_cursor(scope, offset + len(result["items"]), len(rows))
+ return result
+
+
+def preview_workflow_flow(definition, *, user_id, group_id=None, **selectors):
+ """Normalize authored data only: no stored id, runner, source or admission lookup."""
+ if not isinstance(definition, dict):
+ raise WorkflowDefinitionError("A Flow preview requires an authored definition object.")
+ if type(definition.get("definition_version")) is not int or definition["definition_version"] != 3:
+ raise WorkflowFlowUnsupported()
+ # List retains these legacy envelope fields verbatim when saving. They are
+ # not v3 executable fields or revision inputs; leave the editor copy intact
+ # while still rejecting unknown executable fields in the compiler input.
+ authored = {key: value for key, value in definition.items() if key not in _PREVIEW_PRESERVED_FIELDS}
+ normalized = normalize_workflow_definition(
+ authored, {}, authored.get("tasks", []), user_id=user_id, group_id=group_id or "",
+ )
+ workflow = {**authored, **normalized, "user_id": user_id}
+ if group_id:
+ workflow["group_id"] = group_id
+ else:
+ workflow.pop("group_id", None)
+ return workflow_flow_inspection(workflow, source_kind="draft", **selectors)
+
+
+def authorize_workflow_flow_sources(workflow, *, reader_user_id):
+ """Recheck declared source metadata, without expanding queries or reading results.
+
+ Whole-run result authorization traverses all historical execution lineage.
+ Definition-only inspection instead checks its authored source boundaries;
+ execution overlays retain the existing exact payload/lineage authorization.
+ """
+ if not isinstance(workflow, dict) or type(workflow.get("definition_version")) is not int or workflow["definition_version"] != 3:
+ raise WorkflowFlowUnsupported()
+ compiled = compile_workflow_flow(workflow)
+ try:
+ for reference in _references(workflow):
+ authorize_workflow_reference(workflow, reference, actor_user_id=reader_user_id)
+ sources = {}
+ scopes = {}
+ for entry in compiled["nodes"].values():
+ node = entry["node"]
+ if node["kind"] != "for_each":
+ continue
+ iterable = node["iterable"]
+ for value in iterable.get("documents", []):
+ source = {
+ "document_id": value["document_id"], "scope": value["scope_type"],
+ "scope_id": value.get("scope_id") or workflow["user_id"],
+ }
+ sources[canonical_digest(source)] = source
+ for value in iterable.get("scopes", []):
+ scope = {"scope_type": value["scope_type"], "scope_id": value.get("scope_id") or workflow["user_id"]}
+ scopes[canonical_digest(scope)] = scope
+ for scope in scopes.values():
+ if scope["scope_type"] == "personal" and scope["scope_id"] != reader_user_id:
+ raise PermissionError
+ if _default_authorize_scope(scope, actor_user_id=reader_user_id) is False:
+ raise PermissionError
+ if sources:
+ authorize_analysis_sources(reader_user_id, list(sources.values()))
+ for task in [workflow, *compiled["tasks"]]:
+ action = task.get("document_action") or {}
+ if not isinstance(action, dict):
+ raise WorkflowDefinitionError("Document selection configuration must be an object.")
+ if action.get("type", "none") == "none" or action.get("target_mode") == "current_item":
+ continue
+ for scope_type, field in (("group", "active_group_ids"), ("public", "active_public_workspace_id")):
+ for scope_id in action.get(field, []):
+ if _default_authorize_scope(
+ {"scope_type": scope_type, "scope_id": scope_id}, actor_user_id=reader_user_id,
+ ) is False:
+ raise PermissionError
+ identifiers = list(dict.fromkeys([
+ *action.get("document_ids", []), *action.get("right_document_ids", []),
+ *([action["left_document_id"]] if action.get("left_document_id") else []),
+ ]))
+ if identifiers:
+ manifest = resolve_analysis_source_manifest(
+ identifiers, reader_user_id, doc_scope=action.get("doc_scope", "all"),
+ active_group_ids=action.get("active_group_ids", []),
+ active_public_workspace_ids=action.get("active_public_workspace_id", []),
+ )
+ if any(source.get("authorization_status") != "authorized" for source in manifest):
+ raise AnalysisResultUnavailable()
+ except (WorkflowInputError, PermissionError, LookupError) as exc:
+ raise AnalysisResultUnavailable() from exc
+
+
+def workflow_run_flow_inspection(workflow, run_id, *, reader_user_id, **selectors):
+ """Read only after the route has proved current workflow/run/scope access."""
+ store = workflow_runtime_store(workflow, run_id)
+ control = store.read()
+ if control.get("schema_version") != 2:
+ raise WorkflowFlowUnsupported()
+ try:
+ snapshot = store.run_definition()
+ except (AttributeError, KeyError, TypeError) as exc:
+ raise WorkflowFlowUnsupported() from exc
+ if not isinstance(snapshot, dict):
+ raise WorkflowFlowUnsupported()
+ if workflow_definition_revision(snapshot) != control["definition_revision"]:
+ raise WorkflowRuntimeConflict("workflow_definition_changed")
+ if type(snapshot.get("definition_version")) is not int or snapshot["definition_version"] != 3:
+ raise WorkflowFlowUnsupported()
+ authorize_workflow_flow_sources(snapshot, reader_user_id=reader_user_id)
+ return workflow_flow_inspection(
+ snapshot, source_kind="run", run_id=run_id, snapshot_sha256=control["snapshot_ref"]["sha256"], **selectors,
+ )
diff --git a/application/single_app/functions_workflow_journal.py b/application/single_app/functions_workflow_journal.py
index 0d040cd3c..bbc162bcd 100644
--- a/application/single_app/functions_workflow_journal.py
+++ b/application/single_app/functions_workflow_journal.py
@@ -42,6 +42,32 @@ def _public_journal_value(value):
return deepcopy(value)
+def public_workflow_journal_entry(row, kind):
+ """Share the same safe projection between paged and exact journal reads."""
+ if kind not in {"execution", "attempt", "decision"}:
+ raise ValueError("Unsupported public workflow journal kind.")
+ fields = PUBLIC_DECISION_FIELDS if kind == "decision" else PUBLIC_EXECUTION_FIELDS
+ entry = {
+ name: _public_journal_value(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")
+ return entry
+
+
class WorkflowJournalMixin:
"""Atomic decision/cursor/admission updates; control does not grow per execution."""
@@ -278,25 +304,7 @@ def journal_page(self, kind, *, cursor=None, limit=50, execution_id=None):
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: _public_journal_value(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)
+ entries = [public_workflow_journal_entry(row, kind) for row in rows[:limit]]
next_cursor = None
if len(rows) > limit:
next_cursor = base64.urlsafe_b64encode(json.dumps(
diff --git a/application/single_app/route_backend_workflows.py b/application/single_app/route_backend_workflows.py
index edc71df31..cab227a4d 100644
--- a/application/single_app/route_backend_workflows.py
+++ b/application/single_app/route_backend_workflows.py
@@ -100,6 +100,10 @@
)
from functions_workflow_runtime_store import RuntimeUnavailable, WorkflowRuntimeConflict
from functions_workflow_execution_history import workflow_execution_history, workflow_execution_result_page
+from functions_workflow_inspection import (
+ WorkflowFlowDetailTooLarge, WorkflowFlowUnsupported, authorize_workflow_flow_sources, preview_workflow_flow,
+ workflow_flow_inspection, workflow_run_flow_inspection,
+)
from functions_workflow_node_results import WorkflowRecordPageTooLarge
from functions_workflow_loop_history import (
workflow_execution_records_page, workflow_execution_provenance_page, workflow_loop_items_page,
@@ -319,10 +323,111 @@ def _workflow_runtime_response(workflow_id, run_id, *, group=False, action=None)
return jsonify({'error': 'Workflow progress is temporarily unavailable.'}), 503
+def _assert_workflow_flow_reader_scope(workflow, workflow_id, user_id, *, group_id=None, run=None, run_id=None):
+ if not isinstance(workflow, dict) or workflow.get('id') != workflow_id or workflow.get('deleting'):
+ raise LookupError('Workflow not found.')
+ if group_id:
+ if workflow.get('group_id') != group_id:
+ raise LookupError('Workflow not found.')
+ elif workflow.get('user_id') != user_id or workflow.get('group_id'):
+ raise LookupError('Workflow not found.')
+ if run_id is not None:
+ if not isinstance(run, dict) or run.get('id') != run_id or run.get('workflow_id') != workflow_id:
+ raise LookupError('Workflow run not found.')
+ if group_id:
+ if run.get('group_id') != group_id:
+ raise LookupError('Workflow run not found.')
+ elif run.get('user_id') != user_id or run.get('group_id'):
+ raise LookupError('Workflow run not found.')
+
+
+def _workflow_inspection_cache_response(response):
+ """Prevent cached inspection responses from bypassing current access checks."""
+ endpoint = (request.endpoint or '').rsplit('.', 1)[-1]
+ prefix, _, operation = endpoint.partition('_workflow_')
+ if (
+ prefix in {'get_user', 'get_group'} and operation in {
+ 'flow', 'run_flow', 'executions', 'execution_attempts', 'execution_result',
+ 'execution_records', 'execution_provenance', 'loop_items',
+ 'repeat_iterations', 'repeat_state', 'decisions', 'runtime',
+ }
+ or prefix in {'preview_user', 'preview_group'} and operation == 'flow'
+ ):
+ response.headers['Cache-Control'] = 'no-store, private'
+ return response
+
+
+def _workflow_flow_response(workflow_id=None, run_id=None, *, group=False, preview=False):
+ user_id = get_current_user_id()
+ try:
+ if group and not request.args.get('group_id'):
+ raise ValueError('An explicit group scope is required.')
+ group_id = None
+ if group:
+ resolver = _resolve_active_group_for_workflow_management if preview else _resolve_group_workflow_request_group
+ group_id, _ = resolver(user_id)
+ elif preview:
+ _assert_personal_workflow_draft_access(get_settings())
+ allowed = {'node_id', 'section', 'revision', 'cursor', 'limit'}
+ if preview:
+ if set(request.args) - {'group_id'} or any(len(values) != 1 for _, values in request.args.lists()):
+ raise ValueError('Invalid preview query.')
+ data = request.get_json(silent=True)
+ if not isinstance(data, dict) or data.keys() - (allowed | {'definition'}) or 'definition' not in data:
+ raise ValueError('Invalid Flow preview.')
+ selectors = {key: data[key] for key in allowed if key in data}
+ response = preview_workflow_flow(data['definition'], user_id=user_id, group_id=group_id, **selectors)
+ else:
+ if set(request.args) - (allowed | {'group_id'}) or any(len(values) != 1 for _, values in request.args.lists()):
+ raise ValueError('Invalid Flow query.')
+ selectors = {key: request.args[key] for key in allowed if key in request.args}
+ if 'limit' in selectors:
+ selectors['limit'] = int(selectors['limit'])
+ workflow = get_group_workflow(group_id, workflow_id) if group else get_personal_workflow(user_id, workflow_id)
+ run = (
+ get_group_workflow_run(group_id, run_id) if group else get_personal_workflow_run(user_id, run_id)
+ ) if run_id is not None else None
+ _assert_workflow_flow_reader_scope(
+ workflow, workflow_id, user_id, group_id=group_id, run=run, run_id=run_id,
+ )
+ if run_id is not None:
+ if run.get('durable_execution') is not True:
+ raise WorkflowFlowUnsupported()
+ response = workflow_run_flow_inspection(workflow, run_id, reader_user_id=user_id, **selectors)
+ else:
+ authorize_workflow_flow_sources(workflow, reader_user_id=user_id)
+ response = workflow_flow_inspection(workflow, **selectors)
+ return jsonify(response)
+ except WorkflowFlowDetailTooLarge as exc:
+ return jsonify({'error': exc.public_message, 'code': exc.code}), 413
+ except WorkflowFlowUnsupported as exc:
+ return jsonify({'error': exc.public_message, 'code': exc.code}), 409
+ except WorkflowDefinitionConflict as exc:
+ return jsonify({'error': exc.public_message, 'code': 'workflow_flow_revision_changed'}), 409
+ except WorkflowDefinitionError as exc:
+ return jsonify({'error': exc.public_message, 'code': 'invalid_workflow_definition'}), 400
+ except WorkflowRuntimeConflict as exc:
+ return jsonify({'error': exc.public_message, 'code': exc.code}), 409
+ except (PermissionError, AnalysisResultUnavailable):
+ return jsonify({'error': 'Current access to this workflow definition or its sources could not be confirmed.'}), 403
+ except (LookupError, CosmosResourceNotFoundError):
+ return jsonify({'error': 'The selected workflow definition, run or structural node was not found.'}), 404
+ except (ValueError, TypeError, RecursionError):
+ return jsonify({'error': 'Invalid Flow definition or inspection request.'}), 400
+ except (AzureError, RuntimeUnavailable, WorkflowResultStorageUnavailableError) as exc:
+ log_event(
+ '[WORKFLOW_ROUTES] Flow definition inspection failed',
+ extra={'workflow_id': workflow_id, 'run_id': run_id, 'error_type': type(exc).__name__},
+ level=logging.ERROR,
+ )
+ return jsonify({'error': 'Workflow Flow inspection is temporarily unavailable.'}), 503
+
+
def _workflow_execution_history_response(workflow_id, run_id, *, group=False, kind='execution',
execution_id=None, attempt=None, representation=None, iteration=None):
user_id = get_current_user_id()
try:
+ group_id = None
if group:
group_id, _ = _resolve_group_workflow_request_group(user_id)
workflow = get_group_workflow(group_id, workflow_id)
@@ -332,6 +437,23 @@ def _workflow_execution_history_response(workflow_id, run_id, *, group=False, ki
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
+ selectors = {}
+ if 'node_id' in request.args or 'iteration_path' in request.args:
+ if (
+ kind != 'execution' or execution_id is not None or attempt is not None
+ or 'node_id' not in request.args or 'iteration_path' not in request.args
+ or set(request.args) - {'node_id', 'iteration_path', 'limit', 'group_id'}
+ or any(len(values) != 1 for _, values in request.args.lists())
+ or group and not request.args.get('group_id')
+ or len(request.args['iteration_path']) > 2048
+ ):
+ raise ValueError('Invalid exact execution selector.')
+ _assert_workflow_flow_reader_scope(
+ workflow, workflow_id, user_id, group_id=group_id, run=run, run_id=run_id,
+ )
+ selectors = {'node_id': request.args['node_id'], 'iteration_path': json.loads(request.args['iteration_path'])}
+ if not isinstance(selectors['iteration_path'], list):
+ raise ValueError('An exact iteration path must be a JSON list.')
if kind == 'iterations':
response = workflow_repeat_iterations_page(
workflow, run_id, execution_id, reader_user_id=user_id,
@@ -367,6 +489,7 @@ def _workflow_execution_history_response(workflow_id, run_id, *, group=False, ki
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')),
+ **selectors,
)
return jsonify(response)
except WorkflowRuntimeConflict as exc:
@@ -377,7 +500,7 @@ def _workflow_execution_history_response(workflow_id, run_id, *, group=False, ki
return jsonify({'error': 'Workflow execution or attempt not found.'}), 404
except WorkflowRecordPageTooLarge as exc:
return jsonify({'error': exc.public_message, 'code': exc.code, 'record_offset': exc.record_offset}), 413
- except (ValueError, TypeError):
+ except (ValueError, TypeError, RecursionError):
return jsonify({'error': 'Invalid execution, attempt or page request.'}), 400
except (AzureError, RuntimeUnavailable, WorkflowResultStorageUnavailableError) as exc:
log_event('[WORKFLOW_ROUTES] Execution history read failed',
@@ -1052,6 +1175,62 @@ def _stream_group_workflow_activity(user_id, group_id, conversation_id='', workf
def register_route_backend_workflows(bp):
+ bp.after_request(_workflow_inspection_cache_response)
+
+ @bp.route('/api/user/workflows//flow', methods=['GET'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @user_required
+ @enabled_required('allow_user_workflows')
+ @workflow_user_required
+ def get_user_workflow_flow(workflow_id):
+ return _workflow_flow_response(workflow_id)
+
+ @bp.route('/api/group/workflows//flow', methods=['GET'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @user_required
+ @enabled_required('enable_group_workspaces')
+ @enabled_required('allow_group_workflows')
+ def get_group_workflow_flow(workflow_id):
+ return _workflow_flow_response(workflow_id, group=True)
+
+ @bp.route('/api/user/workflows//runs//flow', methods=['GET'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @user_required
+ @enabled_required('allow_user_workflows')
+ @workflow_user_required
+ def get_user_workflow_run_flow(workflow_id, run_id):
+ return _workflow_flow_response(workflow_id, run_id)
+
+ @bp.route('/api/group/workflows//runs//flow', methods=['GET'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @user_required
+ @enabled_required('enable_group_workspaces')
+ @enabled_required('allow_group_workflows')
+ def get_group_workflow_run_flow(workflow_id, run_id):
+ return _workflow_flow_response(workflow_id, run_id, group=True)
+
+ @bp.route('/api/user/workflows/flow-preview', methods=['POST'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @user_required
+ @enabled_required('allow_user_workflows')
+ @workflow_user_required
+ def preview_user_workflow_flow():
+ return _workflow_flow_response(preview=True)
+
+ @bp.route('/api/group/workflows/flow-preview', methods=['POST'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @user_required
+ @enabled_required('enable_group_workspaces')
+ @enabled_required('allow_group_workflows')
+ def preview_group_workflow_flow():
+ return _workflow_flow_response(group=True, preview=True)
+
@bp.route('/api/user/workflows//runs//executions//iterations', methods=['GET'])
@swagger_route(security=get_auth_security())
@login_required
diff --git a/application/v2_ui/package-lock.json b/application/v2_ui/package-lock.json
index 5264f6578..ee7f306a4 100644
--- a/application/v2_ui/package-lock.json
+++ b/application/v2_ui/package-lock.json
@@ -8,6 +8,7 @@
"name": "simplechat-v2-ui",
"version": "0.1.0",
"dependencies": {
+ "@xyflow/react": "12.11.6",
"clsx": "^2.1.1",
"hast-util-to-text": "^4.0.2",
"highlight.js": "^11.11.1",
@@ -1509,6 +1510,55 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha1-NoyWGhjech2oIA6AvzlD+1MTavI=",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha1-sTq6iyRCtAaMmp5tHYL4vOp3/AI=",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha1-QSuQ6EhwKF8v+KhGxutgNE8SpBw=",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha1-vXpF/AqMMWemMWdeYbwsorBY1KM=",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha1-ETa8V+nds8OQ3MybX/O30rjZRwY=",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha1-3Msy0cVrHhxuDxGA2ZSJbwOLxAs=",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/debug/-/debug-4.1.13.tgz",
@@ -1589,8 +1639,9 @@
"version": "18.3.7",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha1-uJ3fLNg7T+r8xOLqQa/fuVoNGU8=",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -1628,6 +1679,76 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@xyflow/react": {
+ "version": "12.11.6",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@xyflow/react/-/react-12.11.6.tgz",
+ "integrity": "sha1-6UxtbZkQ5eT22XuabCVGSBe4VoU=",
+ "license": "MIT",
+ "dependencies": {
+ "@xyflow/system": "0.0.82",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=17",
+ "@types/react-dom": ">=17",
+ "react": ">=17",
+ "react-dom": ">=17"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@xyflow/react/node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha1-fWuyAmoUJBXdi+iJHXhw5tvmX1U=",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.82",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@xyflow/system/-/system-0.0.82.tgz",
+ "integrity": "sha1-TiEgEJDzCdghQYNKa/kOByuSnFc=",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
"node_modules/bail": {
"version": "2.0.2",
"resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bail/-/bail-2.0.2.tgz",
@@ -1757,6 +1878,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha1-jCCfNZqTrDAkBKEBYbUB66nAnHc=",
+ "license": "MIT"
+ },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/clsx/-/clsx-2.1.1.tgz",
@@ -1789,6 +1916,112 @@
"integrity": "sha1-7EjA8+mT5QZIyG2lWeJhCZXPmJo=",
"license": "MIT"
},
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha1-OVsoM9+scVB/EqwvevI7+BneJOI=",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha1-X8dShOnCN1w2yDlBGgz1UMv8TV4=",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha1-mUqunNI8cZ9TteEOOgphCMaWB7o=",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha1-llisOKIUDVnTRhYPH2ww/aC9EvQ=",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha1-PEeqWzLFs9+1bvP9Q0IHimMrQA0=",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha1-wlM4IH76csxbm9FFihpBkB8eGzE=",
+ "license": "ISC",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha1-YoTSonCChbGrt+IB7aQ4CvNeY7A=",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha1-aGn93hRIhoB3/dWYkgDLYbKhZF8=",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha1-0T9BZccyF//qpUKVzWlps+eu6PM=",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-4.4.3.tgz",
@@ -3511,7 +3744,6 @@
"resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react/-/react-18.3.1.tgz",
"integrity": "sha1-SauJIAnFOTNiW9FrJTP8dUyrKJE=",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -4011,6 +4243,15 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.7.0",
+ "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
+ "integrity": "sha1-bctm71aeAvGGr2s9V19BTOdG4Y8=",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vfile/-/vfile-6.0.3.tgz",
diff --git a/application/v2_ui/package.json b/application/v2_ui/package.json
index f8a85b3ea..b90cca52d 100644
--- a/application/v2_ui/package.json
+++ b/application/v2_ui/package.json
@@ -11,6 +11,7 @@
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
+ "@xyflow/react": "12.11.6",
"clsx": "^2.1.1",
"hast-util-to-text": "^4.0.2",
"highlight.js": "^11.11.1",
diff --git a/application/v2_ui/public/licenses/workflow-flow-notices.txt b/application/v2_ui/public/licenses/workflow-flow-notices.txt
new file mode 100644
index 000000000..e5dafb6bb
--- /dev/null
+++ b/application/v2_ui/public/licenses/workflow-flow-notices.txt
@@ -0,0 +1,187 @@
+Workflow Flow third-party notices
+
+These packages are pinned through application/v2_ui/package-lock.json.
+Vite copies this notice to /static/v2/licenses/workflow-flow-notices.txt.
+Runtime JavaScript and CSS are bundled locally; no Pro examples are included.
+
+========================================================================
+@xyflow/react 12.11.6 and @xyflow/system 0.0.82
+MIT License
+
+Copyright (c) 2019-2025 webkid GmbH
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+========================================================================
+classcat 5.0.5
+MIT License
+
+Copyright © Jorge Bucaran <>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+========================================================================
+zustand 4.5.7 (React Flow's nested dependency)
+MIT License
+
+Copyright (c) 2019 Paul Henschel
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+========================================================================
+use-sync-external-store 1.7.0
+MIT License
+
+Copyright (c) Meta Platforms, Inc. and affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+========================================================================
+d3-drag 3.0.0, d3-dispatch 3.0.1, d3-selection 3.0.0,
+d3-interpolate 3.0.1, d3-zoom 3.0.0, d3-transition 3.0.1, d3-timer 3.0.1
+ISC License
+
+Copyright 2010-2021 Mike Bostock
+
+Permission to use, copy, modify, and/or distribute this software for any purpose
+with or without fee is hereby granted, provided that the above copyright notice
+and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
+OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
+TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+THIS SOFTWARE.
+
+========================================================================
+d3-color 3.1.0
+ISC License
+
+Copyright 2010-2022 Mike Bostock
+
+Permission to use, copy, modify, and/or distribute this software for any purpose
+with or without fee is hereby granted, provided that the above copyright notice
+and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
+OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
+TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+THIS SOFTWARE.
+
+========================================================================
+d3-ease 3.0.1
+BSD-3-Clause License
+
+Copyright 2010-2021 Mike Bostock
+Copyright 2001 Robert Penner
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of the author nor the names of contributors may be used to
+ endorse or promote products derived from this software without specific prior
+ written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+========================================================================
+Type declarations supplied with @xyflow/system (not browser runtime code):
+@types/d3-drag 3.0.7, @types/d3-selection 3.0.11,
+@types/d3-interpolate 3.0.4, @types/d3-color 3.1.3,
+@types/d3-transition 3.0.9, @types/d3-zoom 3.0.8
+
+ MIT License
+
+ Copyright (c) Microsoft Corporation.
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE
diff --git a/application/v2_ui/src/components/workflows/WorkflowDefinitionInspector.tsx b/application/v2_ui/src/components/workflows/WorkflowDefinitionInspector.tsx
new file mode 100644
index 000000000..4dee4c08a
--- /dev/null
+++ b/application/v2_ui/src/components/workflows/WorkflowDefinitionInspector.tsx
@@ -0,0 +1,172 @@
+// WorkflowDefinitionInspector.tsx
+// Bounded configuration inspection shared by diagram and textual structure selection.
+
+import { useEffect, useState } from 'react';
+import { ApiError } from '../../lib/apiClient';
+import {
+ fetchWorkflowInspectionDetails, INSPECTION_SECTIONS, inspectionSourceKey, workflowInspectionBindings,
+ type InspectionJson, type WorkflowFlowProjection, type WorkflowInspectionDetails,
+ type WorkflowInspectionNode, type WorkflowInspectionSection, type WorkflowInspectionTarget,
+} from '../../lib/workflowInspection';
+import { isFlowBinding, isFlowPredicate, predicateSummary } from '../../lib/workflowFlow';
+import { workflowErrorMessage, workflowScopeKey, type WorkflowScope } from '../../lib/workflowEditor';
+import { GlassButton } from '../ui/primitives';
+
+const sectionLabels: Record = {
+ configuration: 'Configuration', inputs: 'Typed inputs', condition: 'Condition',
+ outputs: 'Declared outputs', state: 'Authored Repeat state', selection: 'Source selection',
+};
+
+function DetailValue({ value, onSelectNode }: { value: InspectionJson; onSelectNode: (id: string) => void }) {
+ if (isFlowBinding(value)) {
+ const source = value.source;
+ const producerId = source.kind === 'node_output' ? source.node_id : source.loop_id;
+ const output = source.kind === 'node_output' ? source.output
+ : source.kind === 'repeat_state' ? `current state ${source.state_name}` : 'current frozen item';
+ return
+
{value.name}: {value.expected_kind}. {value.required ? 'Required' : 'Optional'}.
+ {' '}{value.allow_partial ? 'Explicitly accepts partial data.' : 'Does not accept partial output.'}
+
onSelectNode(producerId)}>Inspect producer {producerId}
+
Source: {source.kind}; output: {output}; scope: current instance.
+
;
+ }
+ if (isFlowPredicate(value)) return
+
{predicateSummary(value)}
+
+ Exact normalized condition
+ {JSON.stringify(value, null, 2)}
+
+
;
+ if (typeof value === 'string') return {value || '(empty text)'}
;
+ return
+ {JSON.stringify(value, null, 2)}
+ ;
+}
+
+export function WorkflowDefinitionInspector({
+ scope, target, projection, node, onSelectNode, onDetailsChange, onUnavailable,
+}: {
+ scope: WorkflowScope;
+ target: WorkflowInspectionTarget;
+ projection: WorkflowFlowProjection;
+ node: WorkflowInspectionNode;
+ onSelectNode: (id: string) => void;
+ onDetailsChange: (details: WorkflowInspectionDetails | null) => void;
+ onUnavailable: (status: number) => void;
+}) {
+ const [section, setSection] = useState('configuration');
+ const [cursor, setCursor] = useState(null);
+ const [previousCursors, setPreviousCursors] = useState<(string | null)[]>([]);
+ const [refresh, setRefresh] = useState(0);
+ const [details, setDetails] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const sourceKey = inspectionSourceKey(projection.source);
+ const scopeKey = workflowScopeKey(scope);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ setDetails(null);
+ onDetailsChange(null);
+ setLoading(true);
+ setError('');
+ void fetchWorkflowInspectionDetails(scope, target, projection.source, node.id, section, cursor, controller.signal)
+ .then((page) => {
+ if (controller.signal.aborted) return;
+ if (workflowInspectionBindings(node, page).some((binding) => !projection.nodes.some((producer) => producer.id === binding.sourceId))) {
+ throw new Error('This detail refers to a producer outside the selected compiled definition.');
+ }
+ setDetails(page);
+ onDetailsChange(page);
+ setLoading(false);
+ })
+ .catch((cause: unknown) => {
+ if (controller.signal.aborted) return;
+ setDetails(null);
+ onDetailsChange(null);
+ setLoading(false);
+ setError(workflowErrorMessage(cause, 'Could not read this node configuration.'));
+ if (cause instanceof ApiError && [401, 403, 404, 409].includes(cause.status)) onUnavailable(cause.status);
+ });
+ return () => controller.abort();
+ }, [scopeKey, target, sourceKey, node.id, section, cursor, refresh, onDetailsChange, onUnavailable]);
+
+ const sections = INSPECTION_SECTIONS.filter((item) =>
+ item === 'configuration' || item === 'inputs' && node.inputs_count > 0 ||
+ item === 'outputs' && node.outputs_count > 0 || item === 'condition' && node.has_condition ||
+ item === 'state' && node.kind === 'repeat_until' ||
+ item === 'selection' && (['for_each', 'task'].includes(node.kind) || node.id === projection.root_region_id));
+ const related = projection.edges.filter((edge) => edge.source === node.id || edge.target === node.id);
+ const records = new Map(projection.nodes.map((item) => [item.id, item]));
+ const bindings = workflowInspectionBindings(node, details);
+
+ return
+
Canonical node: {node.id}{node.task_id ? `; task: ${node.task_id}` : ''}
+
+ Inspection section
+ {
+ const next = sections.find((item) => item === event.target.value);
+ if (next) {
+ setSection(next);
+ setCursor(null);
+ setPreviousCursors([]);
+ setDetails(null);
+ onDetailsChange(null);
+ }
+ }}>
+ {sections.map((item) => {sectionLabels[item]} )}
+
+
+
+ setRefresh((value) => value + 1)}>Refresh node details
+ {
+ setCursor(previousCursors.at(-1) ?? null);
+ setPreviousCursors((pages) => pages.slice(0, -1));
+ }}>Previous details page
+ {
+ if (details?.next_cursor) {
+ setPreviousCursors((pages) => [...pages, cursor]);
+ setCursor(details.next_cursor);
+ }
+ }}>Next details page
+
+ {loading ?
Loading selected configuration...
: null}
+ {error ?
{error}
: null}
+ {details ? <>
+
Showing {details.items.length} of {details.total_count} {sectionLabels[section].toLowerCase()} entries.
+ {details.next_cursor ? ' More entries are available on the next page.' : ''}
+
+ {details.items.map((item, index) =>
+
{item.label}
+
+ )}
+
+ {!details.items.length ?
No entries are declared in this section.
: null}
+ {bindings.length ?
+
Declared data connections on this page: {bindings.length}. These are not runtime result values.
+
+ {bindings.map((binding, index) =>
+ {binding.label}
+ onSelectNode(binding.sourceId)}>
+ Inspect source {records.get(binding.sourceId)?.label ?? binding.sourceId}
+
+ )}
+
+
: null}
+ > : null}
+ {related.length ?
+ Control-flow relationships ({related.length})
+
+ {related.map((edge) => {
+ const destination = edge.source === node.id ? edge.target : edge.source;
+ return
+ {edge.source === node.id ? 'To' : 'From'}: {edge.label || edge.kind}.
+ onSelectNode(destination)}>{records.get(destination)?.label ?? destination}
+ ;
+ })}
+
+ : null}
+
;
+}
diff --git a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx
index 2f2272fe7..92377c116 100644
--- a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx
+++ b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx
@@ -10,6 +10,7 @@ import { Pill } from '../workspace/primitives';
import { WorkflowDocumentPicker } from './WorkflowDocumentPicker';
import { WorkflowConditionEditor, WorkflowDecisionFields, WorkflowFlowInputs } from './WorkflowConditionEditor';
import { WorkflowStructuredList } from './WorkflowStructuredList';
+import { WorkflowFlowView } from './WorkflowFlowView';
import {
convertToStructuredWorkflow,
defaultFlowPredicate,
@@ -1383,6 +1384,7 @@ export function WorkflowEditorDialog({
const [confirmClose, setConfirmClose] = useState(false);
const [confirmStructured, setConfirmStructured] = useState(false);
const [schemaFieldErrors, setSchemaFieldErrors] = useState>({});
+ const [showFlowPreview, setShowFlowPreview] = useState(false);
const unsupportedFlow = flowUnsupportedReason(draft, options);
const unsupported = !(options.supported_definition_versions ?? [1, 2]).includes(draft.definition_version) || Boolean(unsupportedFlow);
const readOnly = unsupported || !options.can_manage;
@@ -1406,6 +1408,17 @@ export function WorkflowEditorDialog({
}), [draft.tasks]);
const visibleSchemaErrors = draft.tasks.map((task) => schemaFieldErrors[task.id]).filter(Boolean);
const allErrors = [...validationErrors, ...schemaErrors, ...visibleSchemaErrors];
+ const flowPreview = useMemo(() => {
+ if (!showFlowPreview) return { definition: null, error: '' };
+ if (draft.tasks.some((task) => schemaFieldErrors[task.id])) {
+ return { definition: null, error: 'The current schema edit is invalid. Your List edits are retained; fix the schema before previewing this draft.' };
+ }
+ try {
+ return { definition: workflowForSave(draft, original, scope), error: '' };
+ } catch (cause: unknown) {
+ return { definition: null, error: workflowErrorMessage(cause, 'This draft cannot be previewed safely. Your List edits are retained.') };
+ }
+ }, [showFlowPreview, draft, original, scope, schemaFieldErrors]);
const setWorkflow: Dispatch> = (update) => {
setError('');
setDraft((current) => typeof update === 'function' ? update(current) : update);
@@ -1558,7 +1571,14 @@ export function WorkflowEditorDialog({
) : null}
-
+ {showFlowPreview || draft.definition_version === 3 && !readOnly ?
+
setShowFlowPreview((value) => !value)}>
+ {showFlowPreview ? 'Hide Flow preview' : 'Show Flow preview'}
+
+
Author in List; Flow is a read-only preview. On narrow screens, hide the preview to return to List.
+
: null}
+
+
@@ -1797,6 +1817,12 @@ export function WorkflowEditorDialog({
))}
+ {showFlowPreview ?
+ {flowPreview.error ? {flowPreview.error}
: null}
+ {flowPreview.definition ? : null}
+ : null}
+
{confirmStructured ? (
diff --git a/application/v2_ui/src/components/workflows/WorkflowExecutionHistory.tsx b/application/v2_ui/src/components/workflows/WorkflowExecutionHistory.tsx
index e217706f8..a97c35265 100644
--- a/application/v2_ui/src/components/workflows/WorkflowExecutionHistory.tsx
+++ b/application/v2_ui/src/components/workflows/WorkflowExecutionHistory.tsx
@@ -25,6 +25,7 @@ import {
import {
formatWorkflowIterationPath,
workflowErrorMessage,
+ workflowScopeKey,
type WorkflowConsumedInput,
type WorkflowIterationFrame,
type WorkflowResultReference,
@@ -160,15 +161,18 @@ function decisionSummary(decision: WorkflowExecutionDecisionPreview | WorkflowRu
}
function historyErrorMessage(cause: unknown, fallback: string): string {
- if (cause instanceof ApiError && cause.status === 403) {
- return 'You no longer have access to this workflow run. Reload or ask an owner to restore access.';
- }
- if (cause instanceof ApiError && cause.status === 404) {
- return 'This workflow run history is no longer available.';
+ if (cause instanceof ApiError && (cause.status === 403 || cause.status === 404)) {
+ return historyAccessLostMessage(cause.status);
}
return workflowErrorMessage(cause, fallback);
}
+function historyAccessLostMessage(status: number): string {
+ return status === 403
+ ? 'You no longer have access to this workflow run. Reload or ask an owner to restore access.'
+ : 'This workflow run history is no longer available.';
+}
+
function usePagedResource(
loadPage: (cursor: string | null, signal: AbortSignal) => Promise>,
fallbackError: string,
@@ -510,6 +514,7 @@ function AttemptHistory({
runId,
executionId,
onAccessLost,
+ onSelectIteration,
inspectBoundary = false,
}: {
scope: WorkflowScope;
@@ -517,6 +522,7 @@ function AttemptHistory({
runId: string;
executionId: string;
onAccessLost?: (status: number) => void;
+ onSelectIteration?: (path: WorkflowIterationFrame[]) => void;
inspectBoundary?: boolean;
}) {
const [boundaryOpen, setBoundaryOpen] = useState(false);
@@ -550,8 +556,10 @@ function AttemptHistory({
{boundaryOpen && !page.loading && !page.error ? boundary.node_kind === 'repeat_until' ? : : null}
+ finalOutputAvailable={Boolean(boundary.workflow_result?.result_ref)} onAccessLost={onAccessLost}
+ onSelectIteration={onSelectIteration} /> : : null}
: null}
{page.items.map((attempt) => {
@@ -685,9 +693,10 @@ function ContributorPages({ scope, workflowId, runId, executionId, attempt, onAc
);
}
-function IterationExecutions({ executionIds, scope, workflowId, runId, onAccessLost, iterationLabel = 'item' }: {
+function IterationExecutions({ executionIds, scope, workflowId, runId, onAccessLost, onSelectIteration, iterationLabel = 'item' }: {
executionIds?: string[]; scope: WorkflowScope; workflowId: string; runId: string;
onAccessLost?: (status: number) => void;
+ onSelectIteration?: (path: WorkflowIterationFrame[]) => void;
iterationLabel?: 'item' | 'round';
}) {
const [selected, setSelected] = useState(null);
@@ -696,14 +705,15 @@ function IterationExecutions({ executionIds, scope, workflowId, runId, onAccessL
{executionIds?.map((id) => setSelected(selected === id ? null : id)}>Inspect execution {id} )}
{selected ? : null}
+ executionId={selected} onAccessLost={onAccessLost} onSelectIteration={onSelectIteration} inspectBoundary /> : null}
>
);
}
-function LoopItems({ scope, workflowId, runId, executionId, onAccessLost }: {
+function LoopItems({ scope, workflowId, runId, executionId, onAccessLost, onSelectIteration }: {
scope: WorkflowScope; workflowId: string; runId: string; executionId: string;
onAccessLost?: (status: number) => void;
+ onSelectIteration?: (path: WorkflowIterationFrame[]) => void;
}) {
const loadPage = useCallback((cursor: string | null, signal: AbortSignal) =>
fetchWorkflowLoopItemsPage(scope, workflowId, runId, executionId, cursor, 50, signal),
@@ -727,7 +737,12 @@ function LoopItems({ scope, workflowId, runId, executionId, onAccessLost }: {
{item.item_id}
{formatIterationPath(item.iteration_path)}
{item.record_count !== undefined ? {item.record_count} : null}
-
+ {onSelectIteration ? onSelectIteration(item.iteration_path.map((frame) => ({ ...frame })))}>
+ Use item {item.index + 1} in Flow
+ : null}
+
)}
: null}
@@ -787,9 +802,10 @@ function RepeatStatePages({ scope, workflowId, runId, executionId, iteration, ph
;
}
-function RepeatRound({ round, scope, workflowId, runId, executionId, onAccessLost }: {
+function RepeatRound({ round, scope, workflowId, runId, executionId, onAccessLost, onSelectIteration }: {
round: WorkflowRepeatIterationRecord; scope: WorkflowScope; workflowId: string; runId: string; executionId: string;
onAccessLost?: (status: number) => void;
+ onSelectIteration?: (path: WorkflowIterationFrame[]) => void;
}) {
const [phase, setPhase] = useState<'before' | 'after' | null>(null);
return
@@ -804,6 +820,10 @@ function RepeatRound({ round, scope, workflowId, runId, executionId, onAccessLos
{' '}State after: {round.after_available ? 'committed' : 'not committed'}.
{round.partial ? This round retains partial coverage.
: null}
+ {onSelectIteration ? onSelectIteration(round.iteration_path.map((frame) => ({ ...frame })))}>
+ Use round {round.iteration + 1} in Flow
+ : null}
{(['before', 'after'] as const).map((value) => setPhase(phase === value ? null : value)}>State {value} round {round.iteration + 1} )}
@@ -811,13 +831,14 @@ function RepeatRound({ round, scope, workflowId, runId, executionId, onAccessLos
scope={scope} workflowId={workflowId} runId={runId} executionId={executionId} iteration={round.iteration}
phase={phase} onAccessLost={onAccessLost} /> : null}
+ onAccessLost={onAccessLost} onSelectIteration={onSelectIteration} iterationLabel="round" />
;
}
-function RepeatIterations({ scope, workflowId, runId, executionId, finalOutputAvailable, onAccessLost }: {
+function RepeatIterations({ scope, workflowId, runId, executionId, finalOutputAvailable, onAccessLost, onSelectIteration }: {
scope: WorkflowScope; workflowId: string; runId: string; executionId: string; finalOutputAvailable: boolean;
onAccessLost?: (status: number) => void;
+ onSelectIteration?: (path: WorkflowIterationFrame[]) => void;
}) {
const [finalOpen, setFinalOpen] = useState(false);
const loadPage = useCallback((cursor: string | null, signal: AbortSignal) =>
@@ -835,13 +856,13 @@ function RepeatIterations({ scope, workflowId, runId, executionId, finalOutputAv
{!page.loading && !page.error && !page.items.length ? No rounds have been admitted for this Repeat execution.
: null}
{page.items.map((round) => )}
+ runId={runId} executionId={executionId} onAccessLost={onAccessLost} onSelectIteration={onSelectIteration} />)}
{finalOutputAvailable ? setFinalOpen(!finalOpen)}>
{finalOpen ? 'Close Repeat final outputs' : 'Inspect Repeat final outputs'}
: No final Repeat result has been committed. A batch-limit pause does not expose final exports.
}
{finalOpen && !page.loading && !page.error ? : null}
+ executionId={executionId} onAccessLost={onAccessLost} onSelectIteration={onSelectIteration} /> : null}
;
}
@@ -922,6 +943,101 @@ function DecisionHistory({
);
}
+export interface WorkflowExecutionInspectorProps {
+ scope: WorkflowScope;
+ workflowId: string;
+ runId: string;
+ execution: WorkflowExecutionRecord;
+ onAccessLost?: (status: number) => void;
+ onSelectIteration?: (path: WorkflowIterationFrame[]) => void;
+ // History can collapse the paged panels without hiding the shared execution summary.
+ expanded?: boolean;
+ onToggle?: () => void;
+}
+
+function ScopedWorkflowExecutionInspector({
+ scope,
+ workflowId,
+ runId,
+ execution,
+ onAccessLost,
+ onSelectIteration,
+ expanded = true,
+ onToggle,
+}: WorkflowExecutionInspectorProps) {
+ const [accessLost, setAccessLost] = useState(null);
+ const handleAccessLost = useCallback((status: number) => {
+ setAccessLost(status);
+ onAccessLost?.(status);
+ }, [onAccessLost]);
+ const executionId = execution.execution_id;
+ const validation = validationSummary(execution.workflow_validation);
+ const resultSummary = resultReferenceSummary(execution.workflow_result?.result_ref);
+ const inputs = execution.consumed_inputs ?? execution.workflow_result?.consumed_inputs;
+ const iterationPath = formatIterationPath(execution.iteration_path);
+ const decision = decisionSummary(execution.decision);
+
+ if (accessLost !== null) {
+ return
+ {historyAccessLostMessage(accessLost)}
+
;
+ }
+
+ return (
+
+
+ {onToggle ?
:
}
+ label={execution.node_kind === 'for_each'
+ ? `${expanded ? 'Hide' : 'Show'} frozen items for ${executionId}`
+ : execution.node_kind === 'repeat_until'
+ ? `${expanded ? 'Hide' : 'Show'} Repeat rounds for ${executionId}`
+ : `${expanded ? 'Hide' : 'Show'} execution attempts for ${executionId}`}
+ onClick={onToggle}
+ /> : null}
+
{execution.state || 'unknown'}
+
+ {text(execution.node_id) || 'Unsupported node'} · {text(execution.node_kind) || 'unsupported kind'}
+
+
+
+
{executionId}
+
{Number.isFinite(Number(execution.attempt)) ? Number(execution.attempt) : 'unknown'}
+ {text(execution.task_id) ?
{text(execution.task_id)} : null}
+ {Number.isFinite(Number(execution.sequence)) ?
{Number(execution.sequence)} : null}
+ {text(execution.region_id) ?
{text(execution.region_id)} : null}
+ {iterationPath ?
{iterationPath} : null}
+ {text(execution.reason_code) ?
{text(execution.reason_code)} : null}
+ {decision ?
{decision} : null}
+ {validation ?
{validation} : null}
+ {resultSummary ?
{resultSummary} : null}
+
+
+
+ {expanded ? (
+ execution.node_kind === 'for_each' ? : execution.node_kind === 'repeat_until' ? :
+ ) : null}
+
+ );
+}
+
+export function WorkflowExecutionInspector(props: WorkflowExecutionInspectorProps) {
+ const { scope, workflowId, runId, execution } = props;
+ const identity = JSON.stringify([
+ workflowScopeKey(scope), workflowId, runId, execution.execution_id,
+ execution.node_id, execution.iteration_path, execution.attempt,
+ ]);
+ return ;
+}
+
export function WorkflowExecutionHistory({
scope,
workflowId,
@@ -969,58 +1085,13 @@ export function WorkflowExecutionHistory({
{page.items.map((execution) => {
const executionId = execution.execution_id;
const expanded = expandedExecutionId === executionId;
- const validation = validationSummary(execution.workflow_validation);
- const resultSummary = resultReferenceSummary(execution.workflow_result?.result_ref);
- const inputs = execution.consumed_inputs ?? execution.workflow_result?.consumed_inputs;
- const iterationPath = formatIterationPath(execution.iteration_path);
- const decision = decisionSummary(execution.decision);
return (
-
-
-
:
}
- label={execution.node_kind === 'for_each'
- ? `${expanded ? 'Hide' : 'Show'} frozen items for ${executionId}`
- : execution.node_kind === 'repeat_until'
- ? `${expanded ? 'Hide' : 'Show'} Repeat rounds for ${executionId}`
- : `${expanded ? 'Hide' : 'Show'} execution attempts for ${executionId}`}
- onClick={() => setExpandedExecutionId(expanded ? null : executionId)}
- />
-
{execution.state || 'unknown'}
-
- {text(execution.node_id) || 'Unsupported node'} · {text(execution.node_kind) || 'unsupported kind'}
-
-
-
-
{executionId}
-
{Number.isFinite(Number(execution.attempt)) ? Number(execution.attempt) : 'unknown'}
- {text(execution.task_id) ?
{text(execution.task_id)} : null}
- {Number.isFinite(Number(execution.sequence)) ?
{Number(execution.sequence)} : null}
- {text(execution.region_id) ?
{text(execution.region_id)} : null}
- {iterationPath ?
{iterationPath} : null}
- {text(execution.reason_code) ?
{text(execution.reason_code)} : null}
- {decision ?
{decision} : null}
- {validation ?
{validation} : null}
- {resultSummary ?
{resultSummary} : null}
-
-
-
- {expanded ? (
- execution.node_kind === 'for_each' ? : execution.node_kind === 'repeat_until' ? :
- ) : null}
-
+ setExpandedExecutionId(expanded ? null : executionId)}
+ />
);
})}
diff --git a/application/v2_ui/src/components/workflows/WorkflowExecutionInspector.tsx b/application/v2_ui/src/components/workflows/WorkflowExecutionInspector.tsx
new file mode 100644
index 000000000..25da1a133
--- /dev/null
+++ b/application/v2_ui/src/components/workflows/WorkflowExecutionInspector.tsx
@@ -0,0 +1,7 @@
+// WorkflowExecutionInspector.tsx
+// Flow and history use the same scoped, read-only execution inspection panels.
+
+export {
+ WorkflowExecutionInspector,
+ type WorkflowExecutionInspectorProps,
+} from './WorkflowExecutionHistory';
diff --git a/application/v2_ui/src/components/workflows/WorkflowFlowCanvas.tsx b/application/v2_ui/src/components/workflows/WorkflowFlowCanvas.tsx
new file mode 100644
index 000000000..a7df7d9bd
--- /dev/null
+++ b/application/v2_ui/src/components/workflows/WorkflowFlowCanvas.tsx
@@ -0,0 +1,323 @@
+// WorkflowFlowCanvas.tsx
+// A local, read-only renderer. Geometry and selection never reach the workflow editor.
+
+import { memo, useCallback, useEffect, useId, useMemo, useRef, useState, type Dispatch, type KeyboardEvent, type SetStateAction } from 'react';
+import {
+ Handle, MarkerType, Position, ReactFlow,
+ type Edge, type Node, type NodeChange, type NodeProps, type ReactFlowInstance,
+} from '@xyflow/react';
+import { GlassButton } from '../ui/primitives';
+import { layoutWorkflowFlow, visibleWorkflowEdges, visibleWorkflowNode } from '../../lib/workflowFlowLayout';
+import { workflowInspectionBindings, type WorkflowFlowProjection, type WorkflowInspectionDetails, type WorkflowInspectionNode } from '../../lib/workflowInspection';
+import type { WorkflowExecutionRecord } from '../../lib/workflowExecutionHistory';
+import '@xyflow/react/dist/style.css';
+import './WorkflowFlowView.css';
+
+const kindLabels: Record = {
+ region: 'Region', task: 'Task', if: 'If / else', join: 'Join', route: 'Forward route',
+ for_each: 'For each', repeat_until: 'Repeat until', collect: 'Collect',
+};
+
+interface FlowNodeData extends Record {
+ record: WorkflowInspectionNode;
+ container: boolean;
+ collapsed: boolean;
+ chosen: boolean;
+ tabStop: boolean;
+ status: string;
+ onSelect: (id: string) => void;
+ onFocus: (id: string, part?: 'collapse', reveal?: boolean) => void;
+ onNavigate: (id: string, event: KeyboardEvent) => void;
+ onCollapse: (id: string) => void;
+ registerButton: (id: string, button: HTMLButtonElement | null) => void;
+}
+
+type FlowNode = Node;
+
+const DefinitionNode = memo(function DefinitionNode({ data }: NodeProps) {
+ const node = data.record;
+ return
+
+
+
+
+
+
+ data.registerButton(node.id, button)}
+ aria-label={`Select ${node.label} (${kindLabels[node.kind]})`} aria-description={`Canonical node ${node.id}`}
+ aria-pressed={data.chosen} tabIndex={data.tabStop ? 0 : -1}
+ onFocus={(event) => data.onFocus(node.id, undefined, event.currentTarget.matches(':focus-visible'))}
+ onKeyDown={(event) => data.onNavigate(node.id, event)}
+ onClick={() => data.onSelect(node.id)}>
+ {kindLabels[node.kind]}
+ {node.label}
+ {data.status}
+
+ {node.child_region_ids.length > 0 ? data.onFocus(node.id, 'collapse', event.currentTarget.matches(':focus-visible'))}
+ onKeyDown={(event) => data.onNavigate(node.id, event)}
+ onClick={() => data.onCollapse(node.id)}>
+ {data.collapsed ? 'Expand' : 'Collapse'}
+ : null}
+
+ {!data.container ?
+ {node.kind === 'repeat_until' ? `Post-body Until; ${node.max_iterations} rounds per batch`
+ : node.kind === 'for_each' ? `At most ${node.max_items} actual inputs`
+ : node.has_condition ? node.kind === 'task' ? 'Run when condition' : 'Typed condition'
+ : `${node.inputs_count} inputs; ${node.outputs_count} outputs`}
+
: null}
+ {data.container && node.kind === 'repeat_until' ?
+ Post-body Until; {node.max_iterations} rounds per automatic batch
+
: null}
+
+
+
;
+});
+
+const nodeTypes = { workflow: DefinitionNode };
+
+export function WorkflowFlowCanvas({
+ projection, collapsed, selectedId, statuses, observations, details, focusRequest, positions, setPositions, onSelect, onCollapse, onInspect,
+}: {
+ projection: WorkflowFlowProjection;
+ collapsed: ReadonlySet;
+ selectedId: string | null;
+ statuses: ReadonlyMap;
+ observations: ReadonlyMap;
+ details: WorkflowInspectionDetails | null;
+ focusRequest: { id: string; sequence: number } | null;
+ positions: Map;
+ setPositions: Dispatch>>;
+ onSelect: (id: string) => void;
+ onCollapse: (id: string) => void;
+ onInspect: () => void;
+}) {
+ const instance = useRef | null>(null);
+ const containerRef = useRef(null);
+ const buttons = useRef(new Map());
+ const helpId = useId();
+ const [focusedId, setFocusedId] = useState(null);
+ const [error, setError] = useState('');
+ const [dragPan, setDragPan] = useState(() => window.matchMedia('(min-width: 640px) and (pointer: fine)').matches);
+ const boxes = useMemo(() => layoutWorkflowFlow(projection, collapsed), [projection, collapsed]);
+ const records = useMemo(() => new Map(projection.nodes.map((node) => [node.id, node])), [projection.nodes]);
+ const boxMap = useMemo(() => new Map(boxes.map((box) => [box.id, box])), [boxes]);
+ useEffect(() => {
+ const media = window.matchMedia('(min-width: 640px) and (pointer: fine)');
+ const update = () => setDragPan(media.matches);
+ media.addEventListener('change', update);
+ return () => media.removeEventListener('change', update);
+ }, []);
+ useEffect(() => {
+ setPositions((current) => [...current.keys()].every((id) => records.has(id))
+ ? current : new Map([...current].filter(([id]) => records.has(id))));
+ }, [records, setPositions]);
+ const registerButton = useCallback((id: string, button: HTMLButtonElement | null) => {
+ if (button) buttons.current.set(id, button);
+ else buttons.current.delete(id);
+ }, []);
+
+ const onFocus = useCallback((id: string, part?: 'collapse', reveal = false) => {
+ setFocusedId(id);
+ if (!reveal) return;
+ containerRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+ const flow = instance.current;
+ const box = boxMap.get(id);
+ const bounds = containerRef.current?.getBoundingClientRect();
+ if (!flow || !box || !bounds || !flow.viewportInitialized) return;
+ let point = { ...(positions.get(id) ?? box.position) };
+ let parent = box.parentId;
+ while (parent) {
+ const parentBox = boxMap.get(parent);
+ if (!parentBox) break;
+ const offset = positions.get(parent) ?? parentBox.position;
+ point = { x: point.x + offset.x, y: point.y + offset.y };
+ parent = parentBox.parentId;
+ }
+ point = { x: point.x + (part === 'collapse' ? Math.min(box.width - 32, 320) : Math.min(box.width / 2, 140)), y: point.y + 35 };
+ const screen = flow.flowToScreenPosition(point);
+ if (flow.getZoom() < 1 || screen.x < bounds.left + 60 || screen.x > bounds.right - 60 ||
+ screen.y < bounds.top + 30 || screen.y > bounds.bottom - 30) {
+ void flow.setCenter(point.x, point.y, { zoom: Math.max(1, flow.getZoom()), duration: 0 });
+ }
+ }, [boxMap, positions]);
+
+ const onNavigate = useCallback((id: string, event: KeyboardEvent) => {
+ const index = boxes.findIndex((box) => box.id === id);
+ let nextId: string | undefined;
+ if (event.key === 'ArrowDown') nextId = boxes[Math.min(boxes.length - 1, index + 1)]?.id;
+ else if (event.key === 'ArrowUp') nextId = boxes[Math.max(0, index - 1)]?.id;
+ else if (event.key === 'Home') nextId = boxes[0]?.id;
+ else if (event.key === 'End') nextId = boxes.at(-1)?.id;
+ else if (event.key === 'ArrowLeft') nextId = records.get(id)?.parent_id ?? undefined;
+ else if (event.key === 'ArrowRight') {
+ if (collapsed.has(id)) onCollapse(id);
+ else nextId = boxes.find((box) => box.parentId === id)?.id;
+ } else return;
+ event.preventDefault();
+ event.stopPropagation();
+ if (nextId) {
+ buttons.current.get(nextId)?.focus({ preventScroll: true });
+ onFocus(nextId, undefined, true);
+ }
+ }, [boxes, records, collapsed, onCollapse, onFocus]);
+
+ useEffect(() => {
+ if (focusRequest) {
+ buttons.current.get(focusRequest.id)?.focus({ preventScroll: true });
+ onFocus(focusRequest.id, undefined, true);
+ }
+ }, [focusRequest]);
+
+ const nodes: FlowNode[] = useMemo(() => boxes.map((box) => {
+ const record = records.get(box.id);
+ if (!record) throw new Error('The Flow layout lost a canonical node.');
+ return {
+ id: box.id, type: 'workflow', parentId: box.parentId, position: positions.get(box.id) ?? box.position,
+ width: box.width, height: box.height, style: { width: box.width, height: box.height },
+ draggable: dragPan && !box.container && record.kind !== 'region',
+ selectable: false, connectable: false, focusable: false, deletable: false,
+ extent: box.parentId ? 'parent' : undefined,
+ data: {
+ record, container: box.container, collapsed: collapsed.has(box.id), chosen: selectedId === box.id,
+ tabStop: (focusedId && boxMap.has(focusedId) ? focusedId : boxes[0]?.id) === box.id,
+ status: statuses.get(box.id) ?? (projection.source.kind === 'run' ? 'Not loaded' : 'Definition'),
+ onSelect, onFocus, onNavigate, onCollapse, registerButton,
+ },
+ };
+ }), [boxes, records, positions, collapsed, selectedId, focusedId, boxMap, statuses, dragPan,
+ projection.source.kind, onSelect, onFocus, onNavigate, onCollapse, registerButton]);
+
+ const edges: Edge[] = useMemo(() => {
+ const connections = new Map }>();
+ for (const edge of visibleWorkflowEdges(projection, collapsed)) {
+ const sourceHandle = records.get(edge.target)?.parent_id === edge.source ||
+ ['then', 'else', 'body'].includes(edge.kind) ? 'body' : 'out';
+ const targetHandle = edge.kind === 'repeat' ? 'return'
+ : edge.kind === 'complete' && records.get(edge.target)?.kind === 'region' ? 'finish' : 'in';
+ const decision = observations.get(edge.source)?.decision;
+ const branch = decision?.selected_branch ?? decision?.choice;
+ const branchKnown = records.get(edge.source)?.kind === 'if' && typeof branch === 'string' &&
+ (branch === 'then' || branch === 'else' || records.get(edge.source)?.child_region_ids.includes(branch));
+ const branchEdge = branchKnown && (edge.kind === 'then' || edge.kind === 'else');
+ const taken = branchEdge && (branch === edge.kind || branch === edge.target);
+ const label = branchEdge ? `${edge.label} (${taken ? 'recorded path' : 'not selected'})` : edge.label;
+ const key = JSON.stringify([edge.source, edge.target, sourceHandle, targetHandle]);
+ const existing = connections.get(key);
+ if (existing) {
+ existing.labels.add(label);
+ existing.edge.label = existing.labels.size === 1 ? label : `${existing.labels.size} control paths (inspect relationships)`;
+ } else {
+ connections.set(key, { labels: new Set([label]), edge: {
+ ...edge, label, type: 'smoothstep', sourceHandle, targetHandle,
+ markerEnd: { type: MarkerType.ArrowClosed, color: 'var(--text-2)' },
+ focusable: false, selectable: false, deletable: false, reconnectable: false,
+ className: `workflow-flow-control-edge${taken ? ' workflow-flow-recorded-edge' : ''}`,
+ } });
+ }
+ }
+ const control = [...connections.values()].map(({ edge }) => edge);
+ const selected = selectedId ? records.get(selectedId) : undefined;
+ if (!details || !selected) return control;
+ const dataConnections = new Map();
+ for (const [index, binding] of workflowInspectionBindings(selected, details).entries()) {
+ const source = visibleWorkflowNode(binding.sourceId, records, collapsed);
+ const target = visibleWorkflowNode(selected.id, records, collapsed);
+ if (source === target) continue;
+ const key = JSON.stringify([source, target]);
+ const existing = dataConnections.get(key);
+ if (existing) {
+ existing.count += 1;
+ existing.edge.label = `${existing.count} declared bindings (this page)`;
+ } else {
+ dataConnections.set(key, { count: 1, edge: {
+ id: `binding:${selectedId}:${index}`, source, target, type: 'smoothstep',
+ sourceHandle: 'data-out', targetHandle: 'data-in', label: binding.label,
+ className: 'workflow-flow-data-edge',
+ markerEnd: { type: MarkerType.ArrowClosed, color: 'var(--accent)' },
+ focusable: false, selectable: false, deletable: false, reconnectable: false,
+ } });
+ }
+ }
+ return [...control, ...[...dataConnections.values()].map(({ edge }) => edge)];
+ }, [projection, collapsed, records, observations, details, selectedId]);
+
+ const changePositions = useCallback((changes: NodeChange[]) => {
+ const moved = changes.filter((change) => change.type === 'position' && change.position !== undefined);
+ if (!moved.length) return;
+ setPositions((current) => {
+ const next = new Map(current);
+ for (const change of moved) {
+ if (change.type === 'position' && change.position && boxMap.has(change.id)) next.set(change.id, change.position);
+ }
+ return next;
+ });
+ }, [boxMap, setPositions]);
+
+ const moveSelected = (dx: number, dy: number) => {
+ const box = selectedId ? boxMap.get(selectedId) : undefined;
+ if (!box || box.container || records.get(box.id)?.kind === 'region') return;
+ const position = positions.get(box.id) ?? box.position;
+ const parent = box.parentId ? boxMap.get(box.parentId) : undefined;
+ setPositions((current) => new Map(current).set(box.id, {
+ x: Math.max(0, Math.min(parent ? parent.width - box.width : Infinity, position.x + dx)),
+ y: Math.max(0, Math.min(parent ? parent.height - box.height : Infinity, position.y + dy)),
+ }));
+ };
+ const selectedBox = selectedId ? boxMap.get(selectedId) : undefined;
+ const canMove = Boolean(selectedBox && !selectedBox.container && records.get(selectedBox.id)?.kind !== 'region');
+ const pan = (dx: number, dy: number) => {
+ const flow = instance.current;
+ if (!flow) return;
+ const viewport = flow.getViewport();
+ void flow.setViewport({ ...viewport, x: viewport.x + dx, y: viewport.y + dy }, { duration: 0 });
+ };
+
+ return
+
+ void instance.current?.fitView({ padding: 0.15, maxZoom: 1, duration: 0 })}>Fit Flow
+ void instance.current?.zoomIn({ duration: 0 })}>Zoom in
+ void instance.current?.zoomOut({ duration: 0 })}>Zoom out
+ pan(120, 0)}>Pan view left
+ pan(-120, 0)}>Pan view right
+ pan(0, 120)}>Pan view up
+ pan(0, -120)}>Pan view down
+ setPositions(new Map())}>Reset layout
+ Inspect selected node
+
+
+ Arrow keys move focus through the structure; Enter selects a node. Left returns to its region and Right enters or expands it.
+ Solid arrows show control flow. Dashed arrows show only the selected page of typed bindings, not additional execution paths.
+ A recorded-path label comes only from that exact instance's saved branch decision.
+ On touch screens, use the pan buttons; page scrolling and browser zoom remain available.
+
+ {error ?
{error}
: null}
+
+
+ id={`workflow-flow-${helpId.replaceAll(':', '')}`}
+ nodes={nodes} edges={edges} nodeTypes={nodeTypes}
+ onInit={(flow) => { instance.current = flow; }}
+ onNodesChange={changePositions}
+ onError={() => setError('Flow geometry is unavailable. Use List or reload the view.')}
+ nodesConnectable={false} edgesReconnectable={false} nodesFocusable={false} edgesFocusable={false}
+ elementsSelectable={false} selectNodesOnDrag={false} deleteKeyCode={null}
+ selectionKeyCode={null} multiSelectionKeyCode={null}
+ zoomActivationKeyCode={null} panActivationKeyCode={null}
+ panOnDrag={dragPan} zoomOnScroll={false} zoomOnPinch={false} zoomOnDoubleClick={false} preventScrolling={false}
+ minZoom={0.005} maxZoom={2} fitView fitViewOptions={{ padding: 0.15, maxZoom: 1 }}
+ defaultMarkerColor={null}
+ />
+
+ {selectedBox ?
+ Move the selected box (view only):
+ moveSelected(-16, 0)}>Move box left
+ moveSelected(16, 0)}>Move box right
+ moveSelected(0, -16)}>Move box up
+ moveSelected(0, 16)}>Move box down
+
: null}
+
;
+}
diff --git a/application/v2_ui/src/components/workflows/WorkflowFlowDialog.tsx b/application/v2_ui/src/components/workflows/WorkflowFlowDialog.tsx
new file mode 100644
index 000000000..23578fd4e
--- /dev/null
+++ b/application/v2_ui/src/components/workflows/WorkflowFlowDialog.tsx
@@ -0,0 +1,49 @@
+// WorkflowFlowDialog.tsx
+// Standalone reader access does not depend on edit permission or an inactive run.
+
+import { useEffect, useRef } from 'react';
+import type { WorkflowScope } from '../../lib/workflowEditor';
+import { Modal } from '../ui/Modal';
+import { WorkflowFlowView } from './WorkflowFlowView';
+
+export function WorkflowFlowDialog({ scope, workflowId, onClose }: {
+ scope: WorkflowScope;
+ workflowId: string;
+ onClose: () => void;
+}) {
+ const contentRef = useRef(null);
+ useEffect(() => {
+ const previous = document.activeElement;
+ const dialog = contentRef.current?.closest('[role="dialog"]');
+ contentRef.current?.focus();
+ const trap = (event: KeyboardEvent) => {
+ if (event.key !== 'Tab' || !dialog) return;
+ const focusable = Array.from(dialog.querySelectorAll(
+ 'button, a[href], input, select, textarea, [tabindex]',
+ )).filter((element) => element.tabIndex >= 0 && !element.matches(':disabled') &&
+ !element.closest('[hidden], [inert]') && element.getClientRects().length > 0);
+ const first = focusable[0];
+ const last = focusable.at(-1);
+ if (!first || !last) return;
+ if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && (document.activeElement === last || !dialog.contains(document.activeElement))) {
+ event.preventDefault();
+ first.focus();
+ }
+ };
+ document.addEventListener('keydown', trap);
+ return () => {
+ document.removeEventListener('keydown', trap);
+ if (previous instanceof HTMLElement && previous.isConnected) previous.focus();
+ };
+ }, []);
+
+ return
+
+
+
+ ;
+}
diff --git a/application/v2_ui/src/components/workflows/WorkflowFlowView.css b/application/v2_ui/src/components/workflows/WorkflowFlowView.css
new file mode 100644
index 000000000..2b5f54942
--- /dev/null
+++ b/application/v2_ui/src/components/workflows/WorkflowFlowView.css
@@ -0,0 +1,180 @@
+/* WorkflowFlowView.css - Local, theme-aware, read-only workflow inspection. */
+.workflow-flow {
+ --xy-background-color: var(--surface-sunken);
+ --xy-node-color: var(--text-1);
+ --xy-edge-stroke: var(--text-2);
+ --xy-edge-label-color: var(--text-1);
+ --xy-edge-label-background-color: var(--surface-solid);
+ --xy-attribution-background-color: var(--surface-solid);
+ min-width: 0;
+}
+
+.workflow-flow-canvas {
+ height: min(58vh, 36rem);
+ min-height: 20rem;
+ width: 100%;
+ min-width: 0;
+ overflow: hidden;
+ border: 1px solid var(--edge-strong);
+ border-radius: 0.75rem;
+ background: var(--surface-sunken);
+ touch-action: pan-x pan-y pinch-zoom;
+}
+
+.workflow-flow-canvas .react-flow__pane {
+ touch-action: pan-x pan-y pinch-zoom;
+}
+
+.workflow-flow-node {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ color: var(--text-1);
+ border: 1px solid var(--edge-strong);
+ border-radius: 0.65rem;
+ background: var(--surface-solid);
+}
+
+.workflow-flow-container {
+ background: var(--surface-sunken);
+ border-style: dashed;
+}
+
+.workflow-flow-selected {
+ border: 2px solid var(--accent);
+}
+
+.workflow-flow-node-header {
+ display: flex;
+ align-items: start;
+ gap: 0.4rem;
+ padding: 0.55rem;
+}
+
+/* React Flow's read-only wrappers disable pointer events; only view controls opt back in. */
+.workflow-flow-node-select {
+ min-width: 0;
+ flex: 0 1 18rem;
+ pointer-events: auto;
+ text-align: left;
+ border-radius: 0.35rem;
+}
+
+.workflow-flow-node-select:focus-visible,
+.workflow-flow-collapse:focus-visible {
+ outline: 3px solid var(--accent);
+ outline-offset: 3px;
+}
+
+.workflow-flow-kind,
+.workflow-flow-label,
+.workflow-flow-status {
+ display: block;
+}
+
+.workflow-flow-kind,
+.workflow-flow-status,
+.workflow-flow-node-note {
+ font-size: 0.7rem;
+ color: var(--text-2);
+}
+
+.workflow-flow-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+.workflow-flow-status {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.workflow-flow-node-note {
+ padding: 0 0.55rem 0.35rem;
+ overflow-wrap: anywhere;
+}
+
+.workflow-flow-collapse {
+ pointer-events: auto;
+ font-size: 0.65rem;
+ padding: 0.25rem;
+ color: var(--accent);
+ border: 1px solid var(--edge-strong);
+ border-radius: 0.3rem;
+ background: var(--surface-solid);
+}
+
+.workflow-flow .react-flow__handle {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.workflow-flow .workflow-flow-body-handle {
+ top: 76px;
+ bottom: auto;
+}
+
+.workflow-flow .workflow-flow-return-handle {
+ top: auto;
+ bottom: 18px;
+}
+
+.workflow-flow .workflow-flow-data-source {
+ top: 36px;
+}
+
+.workflow-flow .workflow-flow-data-target {
+ top: 60px;
+}
+
+.workflow-flow-boundary-note {
+ position: absolute;
+ bottom: 2px;
+ left: 8px;
+ font-size: 0.65rem;
+ color: var(--text-2);
+}
+
+.workflow-flow-control-edge .react-flow__edge-path {
+ stroke: var(--text-2);
+}
+
+.workflow-flow-recorded-edge .react-flow__edge-path {
+ stroke-width: 3;
+}
+
+.workflow-flow-data-edge .react-flow__edge-path {
+ stroke: var(--accent);
+ stroke-width: 2;
+ stroke-dasharray: 6 4;
+}
+
+.workflow-flow .react-flow__attribution a {
+ color: var(--text-2);
+}
+
+@media (max-width: 639px) {
+ .workflow-flow-canvas {
+ height: 24rem;
+ min-height: 18rem;
+ }
+}
+
+@media (max-width: 1023px) {
+ .workflow-authoring-preview .workflow-authoring-list {
+ display: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .workflow-flow *,
+ .workflow-flow *::before,
+ .workflow-flow *::after {
+ animation: none;
+ transition: none;
+ }
+}
diff --git a/application/v2_ui/src/components/workflows/WorkflowFlowView.tsx b/application/v2_ui/src/components/workflows/WorkflowFlowView.tsx
new file mode 100644
index 000000000..b8e6e9518
--- /dev/null
+++ b/application/v2_ui/src/components/workflows/WorkflowFlowView.tsx
@@ -0,0 +1,441 @@
+// WorkflowFlowView.tsx
+// Source-isolated saved, draft, and frozen-run inspection over one compiled definition.
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { ApiError } from '../../lib/apiClient';
+import {
+ fetchWorkflowFlowProjection, inspectionNodeHasExecution, inspectionNodeMatchesPath, inspectionNodePath, inspectionSourceKey,
+ type WorkflowFlowProjection, type WorkflowInspectionDetails, type WorkflowInspectionTarget,
+} from '../../lib/workflowInspection';
+import { layoutWorkflowFlow, visibleWorkflowNode } from '../../lib/workflowFlowLayout';
+import {
+ fetchWorkflowExecutionForNode, fetchWorkflowExecutionsPage,
+ type WorkflowExecutionPage, type WorkflowExecutionRecord,
+} from '../../lib/workflowExecutionHistory';
+import {
+ formatWorkflowIterationPath, workflowErrorMessage, workflowScopeKey,
+ type WorkflowIterationFrame, type WorkflowRuntimeProjection, type WorkflowScope,
+} from '../../lib/workflowEditor';
+import { GlassButton, GlassPanel } from '../ui/primitives';
+import { WorkflowDefinitionInspector } from './WorkflowDefinitionInspector';
+import { WorkflowExecutionInspector } from './WorkflowExecutionInspector';
+import { WorkflowFlowCanvas } from './WorkflowFlowCanvas';
+import { WorkflowRepeatProgress } from './WorkflowRepeatProgress';
+
+const sourceLabels = { saved: 'Saved definition', draft: 'Unsaved draft', run: "Run's frozen definition" };
+
+function SelectedExecution({
+ scope, workflowId, runId, nodeId, path, runtimeVersion, onUnavailable, onSelectIteration, onObserved,
+}: {
+ scope: WorkflowScope;
+ workflowId: string;
+ runId: string;
+ nodeId: string;
+ path: WorkflowIterationFrame[];
+ runtimeVersion?: number;
+ onUnavailable: (status: number) => void;
+ onSelectIteration: (path: WorkflowIterationFrame[]) => void;
+ onObserved: (record: WorkflowExecutionRecord | null, loaded: boolean) => void;
+}) {
+ const [execution, setExecution] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [refresh, setRefresh] = useState(0);
+ const pathKey = JSON.stringify(path);
+ const scopeKey = workflowScopeKey(scope);
+ useEffect(() => {
+ const controller = new AbortController();
+ setExecution(null);
+ onObserved(null, false);
+ setLoading(true);
+ setError('');
+ void fetchWorkflowExecutionForNode(scope, workflowId, runId, nodeId, path, controller.signal)
+ .then((record) => {
+ if (controller.signal.aborted) return;
+ setExecution(record);
+ onObserved(record, true);
+ setLoading(false);
+ })
+ .catch((cause: unknown) => {
+ if (controller.signal.aborted) return;
+ setExecution(null);
+ onObserved(null, false);
+ setError(workflowErrorMessage(cause, 'Could not read the selected execution.'));
+ setLoading(false);
+ if (cause instanceof ApiError && [401, 403, 404, 409].includes(cause.status)) onUnavailable(cause.status);
+ });
+ return () => controller.abort();
+ }, [scopeKey, workflowId, runId, nodeId, pathKey, runtimeVersion, refresh, onUnavailable, onObserved]);
+ return
+ Run evidence
+ Instance: {formatWorkflowIterationPath(path) || 'Root scope'}.
+ setRefresh((value) => value + 1)}>Refresh selected execution
+ {loading ? Reading exact execution...
: null}
+ {error ? {error}
: null}
+ {!loading && !error && !execution ?
+ No execution recorded for this node in the selected instance. This is not a successful or empty result.
+
: null}
+ {execution ? : null}
+ ;
+}
+
+export function WorkflowFlowView({
+ scope, target, runtime, onAccessLost,
+}: {
+ scope: WorkflowScope;
+ target: WorkflowInspectionTarget;
+ runtime?: WorkflowRuntimeProjection | null;
+ onAccessLost?: (status: number) => void;
+}) {
+ const targetKey = useMemo(() => JSON.stringify(target), [target]);
+ const stableTarget = useMemo(() => target, [targetKey]);
+ const scopeKey = workflowScopeKey(scope);
+ const [loadedProjection, setLoadedProjection] = useState<{ key: string; value: WorkflowFlowProjection } | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [refresh, setRefresh] = useState(0);
+ const requestKey = useMemo(() => JSON.stringify([scopeKey, targetKey, refresh]), [scopeKey, targetKey, refresh]);
+ const projection = loadedProjection?.key === requestKey ? loadedProjection.value : null;
+ const [selectedId, setSelectedId] = useState(null);
+ const [collapsed, setCollapsed] = useState>(new Set());
+ const [path, setPath] = useState([]);
+ const [bindingDetails, setBindingDetails] = useState(null);
+ const [observedExecution, setObservedExecution] = useState<{
+ key: string; value: WorkflowExecutionRecord | null; loaded: boolean;
+ } | null>(null);
+ const [positions, setPositions] = useState(() => new Map());
+ const [view, setView] = useState<'list' | 'flow'>(() => window.matchMedia('(max-width: 639px)').matches ? 'list' : 'flow');
+ const [focusRequest, setFocusRequest] = useState<{ id: string; sequence: number } | null>(null);
+ const inspectorRef = useRef(null);
+ const structureRef = useRef(null);
+ const generationRef = useRef(0);
+ const sourceRef = useRef(null);
+ const knownNodes = useRef(new Set());
+ const [loadedOverlay, setLoadedOverlay] = useState<{ key: string; value: WorkflowExecutionPage } | null>(null);
+ const [overlayCursor, setOverlayCursor] = useState(null);
+ const [overlayPrevious, setOverlayPrevious] = useState<(string | null)[]>([]);
+ const [overlayLoading, setOverlayLoading] = useState(false);
+ const [overlayError, setOverlayError] = useState('');
+
+ const unavailable = useCallback((status: number) => {
+ generationRef.current += 1;
+ setLoadedProjection(null);
+ setLoadedOverlay(null);
+ setObservedExecution(null);
+ setBindingDetails(null);
+ setSelectedId(null);
+ setPath([]);
+ setPositions(new Map());
+ setCollapsed(new Set());
+ setFocusRequest(null);
+ setOverlayCursor(null);
+ setOverlayPrevious([]);
+ knownNodes.current = new Set();
+ sourceRef.current = null;
+ setLoading(false);
+ setError(status === 401 || status === 403 ? 'Current access to this workflow or its contributing sources could not be confirmed. Cached Flow details were removed.'
+ : status === 404 ? 'This workflow inspection is no longer available. Cached Flow details were removed.'
+ : 'The definition or frozen inspection source changed. Refresh Flow before inspecting it again.');
+ if (status === 401 || status === 403 || status === 404) onAccessLost?.(status);
+ }, [onAccessLost]);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ const generation = ++generationRef.current;
+ setLoadedProjection(null);
+ setLoadedOverlay(null);
+ setObservedExecution(null);
+ setBindingDetails(null);
+ setLoading(true);
+ setError('');
+ const timer = window.setTimeout(() => {
+ void fetchWorkflowFlowProjection(scope, stableTarget, controller.signal)
+ .then((next) => {
+ if (controller.signal.aborted || generation !== generationRef.current) return;
+ const identity = JSON.stringify([scopeKey, next.source.scope_id, next.source.kind,
+ next.source.workflow_id, next.source.run_id, next.source.snapshot_sha256,
+ next.source.kind === 'draft' ? null : next.source.definition_revision]);
+ const ids = new Set(next.nodes.map((node) => node.id));
+ const initialCollapsed = next.nodes.filter((node) => ['for_each', 'repeat_until'].includes(node.kind)).map((node) => node.id);
+ if (sourceRef.current !== identity) {
+ setCollapsed(new Set(initialCollapsed));
+ setSelectedId(null);
+ setPath([]);
+ setPositions(new Map());
+ setFocusRequest(null);
+ setOverlayCursor(null);
+ setOverlayPrevious([]);
+ } else {
+ setCollapsed((current) => new Set([
+ ...[...current].filter((id) => ids.has(id)),
+ ...initialCollapsed.filter((id) => !knownNodes.current.has(id)),
+ ]));
+ setSelectedId((current) => current && ids.has(current) ? current : null);
+ }
+ sourceRef.current = identity;
+ knownNodes.current = ids;
+ setLoadedProjection({ key: requestKey, value: next });
+ setLoading(false);
+ })
+ .catch((cause: unknown) => {
+ if (controller.signal.aborted || generation !== generationRef.current) return;
+ setLoadedProjection(null);
+ setLoading(false);
+ setError(workflowErrorMessage(cause, 'Could not load this Flow definition.'));
+ if (cause instanceof ApiError && [401, 403, 404].includes(cause.status)) unavailable(cause.status);
+ });
+ }, stableTarget.kind === 'draft' ? 250 : 0);
+ return () => {
+ window.clearTimeout(timer);
+ controller.abort();
+ };
+ }, [scopeKey, stableTarget, requestKey, unavailable]);
+
+ const sourceKey = projection ? inspectionSourceKey(projection.source) : '';
+ const overlayKey = useMemo(() => JSON.stringify([requestKey, sourceKey, overlayCursor, runtime?.version]),
+ [requestKey, sourceKey, overlayCursor, runtime?.version]);
+ const overlay = loadedOverlay?.key === overlayKey ? loadedOverlay.value : null;
+ useEffect(() => {
+ if (!projection || stableTarget.kind !== 'run') return;
+ const controller = new AbortController();
+ const generation = generationRef.current;
+ setLoadedOverlay(null);
+ setOverlayLoading(true);
+ setOverlayError('');
+ void fetchWorkflowExecutionsPage(scope, stableTarget.workflowId, stableTarget.runId, overlayCursor, 50, controller.signal)
+ .then((page) => {
+ if (controller.signal.aborted || generation !== generationRef.current) return;
+ setLoadedOverlay({ key: overlayKey, value: page });
+ setOverlayLoading(false);
+ })
+ .catch((cause: unknown) => {
+ if (controller.signal.aborted || generation !== generationRef.current) return;
+ setLoadedOverlay(null);
+ setOverlayLoading(false);
+ setOverlayError(workflowErrorMessage(cause, 'Could not load this execution overlay page.'));
+ if (cause instanceof ApiError && [401, 403, 404, 409].includes(cause.status)) unavailable(cause.status);
+ });
+ return () => controller.abort();
+ }, [scopeKey, stableTarget, overlayKey, unavailable]);
+
+ const nodes = useMemo(() => new Map(projection?.nodes.map((node) => [node.id, node]) ?? []), [projection]);
+ const selected = selectedId ? nodes.get(selectedId) : undefined;
+ const selectedPath = selected ? inspectionNodePath(selected, path) : null;
+ const executionKey = JSON.stringify([sourceKey, selectedId, selectedPath, runtime?.version]);
+ const selectedExecution = observedExecution?.key === executionKey ? observedExecution.value : null;
+ const onObserved = useCallback((record: WorkflowExecutionRecord | null, loaded: boolean) => {
+ setObservedExecution({ key: executionKey, value: record, loaded });
+ }, [executionKey]);
+ const observations = useMemo(() => {
+ const matching = new Map();
+ for (const record of [...(overlay?.items ?? []), ...(selectedExecution ? [selectedExecution] : [])]) {
+ const node = nodes.get(record.node_id);
+ if (node && projection && inspectionNodeHasExecution(node, projection.root_region_id) &&
+ inspectionNodeMatchesPath(node, path, record.iteration_path)) {
+ matching.set(node.id, record);
+ }
+ }
+ if (selectedId && observedExecution?.key === executionKey && observedExecution.loaded && !observedExecution.value) {
+ matching.delete(selectedId);
+ }
+ return matching;
+ }, [overlay, selectedExecution, observedExecution, executionKey, selectedId, projection, nodes, path]);
+ const statuses = useMemo(() => {
+ const status = new Map();
+ if (projection && stableTarget.kind === 'run') {
+ for (const node of nodes.values()) {
+ if (!inspectionNodeHasExecution(node, projection.root_region_id)) {
+ status.set(node.id, 'Region grouping; no separate execution');
+ }
+ }
+ }
+ for (const record of observations.values()) {
+ const validation = record.workflow_validation?.status;
+ status.set(record.node_id, `${record.state.replaceAll('_', ' ')}; attempt ${record.attempt}` +
+ (record.workflow_validation ? `; validation ${typeof validation === 'string' ? validation.replaceAll('_', ' ') : 'unavailable'}` : ''));
+ }
+ if (selected && observedExecution?.key === executionKey && observedExecution.loaded && !observedExecution.value) {
+ status.set(selected.id, 'No execution recorded');
+ }
+ const gateNode = runtime?.gate?.node_id ? nodes.get(runtime.gate.node_id) : undefined;
+ if (runtime?.gate && gateNode && inspectionNodeMatchesPath(gateNode, path, runtime.gate.iteration_path)) {
+ status.set(gateNode.id, `${runtime.state.replaceAll('_', ' ')}: ${runtime.gate.reason_code || runtime.gate.kind}`);
+ }
+ return status;
+ }, [observations, observedExecution, executionKey, selected, projection, stableTarget.kind, nodes, path, runtime]);
+
+ const selectNode = useCallback((id: string) => {
+ const node = nodes.get(id);
+ if (!node) {
+ setBindingDetails(null);
+ setError('That relationship is not part of this definition. Refresh Flow to inspect it.');
+ return;
+ }
+ const parents: string[] = [];
+ let parent = node.parent_id;
+ while (parent) {
+ parents.push(parent);
+ parent = nodes.get(parent)?.parent_id ?? null;
+ }
+ setCollapsed((current) => new Set([...current].filter((value) => !parents.includes(value))));
+ if (id !== selectedId) {
+ setSelectedId(id);
+ setObservedExecution(null);
+ setBindingDetails(null);
+ }
+ }, [nodes, selectedId]);
+
+ const toggleCollapse = useCallback((id: string) => {
+ const next = new Set(collapsed);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ setCollapsed(next);
+ if (selectedId && visibleWorkflowNode(selectedId, nodes, next) !== selectedId) {
+ setSelectedId(id);
+ setBindingDetails(null);
+ setObservedExecution(null);
+ setFocusRequest((request) => ({ id, sequence: (request?.sequence ?? 0) + 1 }));
+ }
+ }, [nodes, selectedId, collapsed]);
+
+ const selectIteration = useCallback((nextPath: WorkflowIterationFrame[]) => {
+ setPath(nextPath.map((frame) => ({ ...frame })));
+ setObservedExecution(null);
+ const loopId = nextPath.at(-1)?.loop_id;
+ if (!loopId) return;
+ setCollapsed((current) => new Set([...current].filter((id) => !nextPath.some((frame) => frame.loop_id === id))));
+ const body = projection?.nodes.find((node) => node.kind === 'region' && node.parent_id === loopId);
+ const first = body && projection?.nodes.find((node) => node.parent_id === body.id);
+ if (first || body) setSelectedId((first ?? body)?.id ?? null);
+ setBindingDetails(null);
+ }, [projection]);
+
+ const visibleBoxes = useMemo(() => projection ? layoutWorkflowFlow(projection, collapsed) : [], [projection, collapsed]);
+ const returnToNode = useCallback(() => {
+ if (!selectedId) return;
+ setFocusRequest((request) => ({ id: selectedId, sequence: (request?.sequence ?? 0) + 1 }));
+ }, [selectedId]);
+ useEffect(() => {
+ if (view !== 'list' || !focusRequest) return;
+ const buttons = structureRef.current?.querySelectorAll('[data-workflow-node-id]');
+ Array.from(buttons ?? []).find((button) => button.dataset.workflowNodeId === focusRequest.id)?.focus();
+ }, [focusRequest, view]);
+ const cancelOnly = runtime?.gate?.choices.length === 1 && runtime.gate.choices[0] === 'cancel';
+
+ return
+
+
+
Read-only Flow
+
{sourceLabels[stableTarget.kind]}
+
+
setRefresh((value) => value + 1)}>Refresh Flow
+
+
+ Edit executable steps in List. Moving, expanding, or viewing boxes does not save, run, approve, or publish anything.
+ {stableTarget.kind === 'draft' ? ' This preview updates as you edit; it is not a saved definition or an execution check.' : ''}
+
+ {loading ?
+ {stableTarget.kind === 'draft' ? 'Checking the current List draft...' : 'Loading authorized Flow definition...'}
+
: null}
+ {error ? {error}
: null}
+ {projection ? <>
+
+ {projection.name}; definition v3; {stableTarget.kind === 'draft' ? 'preview digest' : 'revision'} {projection.source.definition_revision}.
+ {projection.source.run_id ? ` Run ${projection.source.run_id}.` : ''}
+
+ {stableTarget.kind === 'run' ?
+
Selected instance: {formatWorkflowIterationPath(path) || 'Root scope'}.
+ {path.length ?
setPath([])}>Return to root instance : null}
+ {runtime?.gate ?
+ Current run gate: {runtime.gate.reason_code || runtime.gate.kind}.
+ {cancelOnly ? ' Cancel only. Retained Repeat progress does not permit another batch.' : ' Decisions remain in the separate runtime panel.'}
+
: null}
+
+ {runtime?.repeat_progress ?
+ This retained observation belongs to the displayed Repeat execution, not every instance of that template.
+ Inspect a selected instance below for its own saved round and state.
+
: null}
+
One execution page is loaded. Unrequested nodes say Not loaded; a partial page is not a whole-run summary.
+ Execution status is not output validation. Submission, approval, and indexed readiness are separate saved observations.
+
+ {
+ setOverlayCursor(overlayPrevious.at(-1) ?? null);
+ setOverlayPrevious((pages) => pages.slice(0, -1));
+ }}>Previous execution overlay page
+ {
+ if (overlay?.next_cursor) {
+ setOverlayPrevious((pages) => [...pages, overlayCursor]);
+ setOverlayCursor(overlay.next_cursor);
+ }
+ }}>Next execution overlay page
+
+ {overlayLoading ?
Loading bounded execution overlay...
: null}
+ {overlayError ?
{overlayError}
: null}
+
: null}
+
+ setView('list')}>Structure list
+ setView('flow')}>Flow diagram
+
+ {view === 'flow' ? inspectorRef.current?.focus()} /> :
+
This is the same read-only structure, not a second editable workflow.
+
+ {visibleBoxes.map((box) => {
+ const node = nodes.get(box.id);
+ if (!node) return null;
+ return
+
+ selectNode(node.id)}>{node.label} ({node.kind.replaceAll('_', ' ')})
+ {statuses.get(node.id) ?? (stableTarget.kind === 'run' ? 'Not loaded' : 'Definition')}
+ {node.child_region_ids.length ? toggleCollapse(node.id)}>
+ {collapsed.has(node.id) ? 'Expand' : 'Collapse'}
+ : null}
+
+ {node.parent_id ? Within {nodes.get(node.parent_id)?.label}.
: null}
+ ;
+ })}
+
+
inspectorRef.current?.focus()}>Inspect selected node
+
}
+ {selected ?
+
+
{selected.label}
+ Return to selected node
+
+
+ {statuses.get(selected.id) ?? (stableTarget.kind === 'run' ? 'Not loaded' : 'Definition only; no run overlay')}
+
+
+ {stableTarget.kind === 'run' ? !inspectionNodeHasExecution(selected, projection.root_region_id) ?
+
This region groups nodes; it has no separate execution record.
+ Inspect its enclosing control or a contained node for run evidence.
+ {selected.parent_id ?
{
+ if (selected.parent_id) selectNode(selected.parent_id);
+ }}>
+ Inspect enclosing control {nodes.get(selected.parent_id)?.label}
+ : null}
+
: selectedPath !== null ?
+ :
+ Choose an exact frozen item or Repeat round before inspecting this template node's run evidence.
+ {selected.loop_ids.map((id) => selectNode(id)}>Inspect enclosing loop {nodes.get(id)?.label ?? id} )}
+ : null}
+ : Select a node to load its configuration and exact run evidence. Results and loop contents are not loaded to draw the diagram.
}
+ > : null}
+ ;
+}
diff --git a/application/v2_ui/src/components/workflows/WorkflowRunHistory.tsx b/application/v2_ui/src/components/workflows/WorkflowRunHistory.tsx
index 85c1e9ee9..e62fee7a1 100644
--- a/application/v2_ui/src/components/workflows/WorkflowRunHistory.tsx
+++ b/application/v2_ui/src/components/workflows/WorkflowRunHistory.tsx
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, FileJson, Loader2 } from 'lucide-react';
import { WorkflowExecutionHistory } from './WorkflowExecutionHistory';
import { WorkflowRuntimePanel } from './WorkflowRuntimePanel';
+import { WorkflowFlowView } from './WorkflowFlowView';
import { GlassButton, GlassPanel } from '../ui/primitives';
import { Pill, RowAction } from '../workspace/primitives';
import { useSectionResource } from '../workspace/useSectionResource';
@@ -17,6 +18,7 @@ import {
type WorkflowRunItem,
type WorkflowRunResultPage,
type WorkflowRunSummary,
+ type WorkflowRuntimeProjection,
type WorkflowScope,
} from '../../lib/workflowEditor';
@@ -309,9 +311,15 @@ export function WorkflowRunHistory({
'Failed to load run history.',
);
const [expandedRunId, setExpandedRunId] = useState(null);
+ const [showFlow, setShowFlow] = useState(false);
+ const [runtimeSnapshot, setRuntimeSnapshot] = useState<{ runId: string; runtime: WorkflowRuntimeProjection | null } | null>(null);
+ const onRuntimeSnapshot = useCallback((runId: string, runtime: WorkflowRuntimeProjection | null) => {
+ setRuntimeSnapshot((current) => current?.runId === runId && current.runtime === runtime ? current : { runId, runtime });
+ }, []);
const [unavailableRun, setUnavailableRun] = useState<{ id: string | null; status: number } | null>(null);
const onAccessLost = useCallback((status: number) => {
setUnavailableRun({ id: expandedRunId, status });
+ setRuntimeSnapshot(null);
}, [expandedRunId]);
const shown = useMemo(() => items.slice(0, 10), [items]);
@@ -359,6 +367,8 @@ export function WorkflowRunHistory({
onClick={() => {
setExpandedRunId(expanded ? null : runId);
setUnavailableRun(null);
+ setShowFlow(false);
+ setRuntimeSnapshot(null);
}}
/>
{status}
@@ -386,18 +396,31 @@ export function WorkflowRunHistory({
) : (
<>
{
void refresh();
onWorkflowRefresh?.();
}}
/>
+ {isStructuredRun ?
+ setShowFlow((value) => !value)}>
+ {showFlow ? 'Hide Flow for this run' : 'Show Flow for this run'}
+
+
: null}
{isStructuredRun ? (
+ showFlow ?
+
+
:
) : (
diff --git a/application/v2_ui/src/components/workflows/WorkflowRuntimePanel.tsx b/application/v2_ui/src/components/workflows/WorkflowRuntimePanel.tsx
index 6d65267e6..4bdce7fad 100644
--- a/application/v2_ui/src/components/workflows/WorkflowRuntimePanel.tsx
+++ b/application/v2_ui/src/components/workflows/WorkflowRuntimePanel.tsx
@@ -214,6 +214,7 @@ export function WorkflowRuntimePanel({
structuredRun = false,
onRuntimeChanged,
onAccessLost,
+ onRuntimeSnapshot,
}: {
scope: WorkflowScope;
workflowId: string;
@@ -222,6 +223,7 @@ export function WorkflowRuntimePanel({
structuredRun?: boolean;
onRuntimeChanged?: () => void;
onAccessLost?: (status: number) => void;
+ onRuntimeSnapshot?: (runId: string, runtime: WorkflowRuntimeProjection | null) => void;
}) {
const scopeKey = workflowScopeKey(scope);
const [enabled, setEnabled] = useState(durable);
@@ -238,6 +240,10 @@ export function WorkflowRuntimePanel({
const requestToken = useRef(0);
const retryRequest = useRef<{ key: string; requestId: string } | null>(null);
+ useEffect(() => {
+ onRuntimeSnapshot?.(runId, runtime);
+ }, [onRuntimeSnapshot, runId, runtime]);
+
useEffect(() => {
setEnabled(durable);
setRuntime(null);
diff --git a/application/v2_ui/src/lib/workflowExecutionHistory.ts b/application/v2_ui/src/lib/workflowExecutionHistory.ts
index 6e3fedfbd..e00f8172c 100644
--- a/application/v2_ui/src/lib/workflowExecutionHistory.ts
+++ b/application/v2_ui/src/lib/workflowExecutionHistory.ts
@@ -2,7 +2,7 @@
// Paged V3 workflow execution-history API contracts and client helpers.
import { api } from './apiClient';
-import { isRecord } from './workspaceAuthoring';
+import { isRecord, sameEditorValue } from './workspaceAuthoring';
import {
workflowUrl,
workflowLoopSelection,
@@ -344,6 +344,37 @@ export async function fetchWorkflowExecutionsPage(
return pageFromResponse(response, 'executions', isExecution, (item) => item.execution_id, limit);
}
+export async function fetchWorkflowExecutionForNode(
+ scope: WorkflowScope,
+ workflowId: string,
+ runId: string,
+ nodeId: string,
+ iterationPath: WorkflowIterationFrame[],
+ signal?: AbortSignal,
+): Promise {
+ if (!validIdentity(nodeId) || !validWorkflowIterationPath(iterationPath)) {
+ throw new Error('Select an exact workflow node and iteration path.');
+ }
+ const requestedPath = iterationPath.map((frame) => ({ ...frame }));
+ const params = pageParams(null, 1);
+ params.set('node_id', nodeId);
+ params.set('iteration_path', JSON.stringify(requestedPath));
+ const response = await api.get(
+ workflowUrl(scope, workflowId, `/runs/${encodeURIComponent(runId)}/executions`, params),
+ signal,
+ );
+ if (!isRecord(response) || response.next_cursor !== null || Object.keys(response).some((key) =>
+ !['executions', 'next_cursor', 'total_count'].includes(key))) {
+ throw new Error('The exact workflow execution lookup returned an unsupported response.');
+ }
+ const page = pageFromResponse(response, 'executions', isExecution, (item) => item.execution_id, 1);
+ if (page.total_count !== page.items.length || page.items.some((execution) =>
+ execution.node_id !== nodeId || !sameEditorValue(execution.iteration_path, requestedPath))) {
+ throw new Error('The workflow execution lookup does not match the selected node and iteration path.');
+ }
+ return page.items[0] ?? null;
+}
+
export async function fetchWorkflowExecutionAttemptsPage(
scope: WorkflowScope,
workflowId: string,
diff --git a/application/v2_ui/src/lib/workflowFlow.ts b/application/v2_ui/src/lib/workflowFlow.ts
index bdfa2a74c..df15c0e53 100644
--- a/application/v2_ui/src/lib/workflowFlow.ts
+++ b/application/v2_ui/src/lib/workflowFlow.ts
@@ -200,7 +200,7 @@ export function isFlowBinding(value: unknown): value is WorkflowFlowBinding {
FLOW_OUTPUT_KINDS.some((kind) => kind === value.expected_kind);
}
-function isRepeatState(value: unknown): value is WorkflowRepeatState {
+export function isRepeatState(value: unknown): value is WorkflowRepeatState {
if (!isRecord(value) || typeof value.name !== 'string' || typeof value.next !== 'string' ||
!isRecord(value.initial) || value.initial.scope !== 'current' || !isRecord(value.output_contract)) return false;
const source = value.initial;
@@ -272,7 +272,7 @@ function isJoinSource(value: unknown): value is WorkflowJoinSource {
return isRecord(value) && typeof value.node_id === 'string' && typeof value.output === 'string';
}
-function isJoinExport(value: unknown): value is WorkflowJoinExport {
+export function isJoinExport(value: unknown): value is WorkflowJoinExport {
return isRecord(value) && typeof value.name === 'string' && typeof value.required === 'boolean' &&
FLOW_OUTPUT_KINDS.some((kind) => kind === value.expected_kind) &&
isJoinSource(value.then) && isJoinSource(value.else);
@@ -1231,7 +1231,10 @@ export function predicateSummary(condition: WorkflowPredicate): string {
const operand = (value: WorkflowOperand) =>
'literal' in value ? JSON.stringify(value.literal) : `${value.input || 'Choose input'}${value.path.replaceAll('/', '.')}`;
if (condition.op === 'all' || condition.op === 'any') {
- return condition.conditions.map(predicateSummary).join(condition.op === 'all' ? ' AND ' : ' OR ');
+ return condition.conditions.map((child) => {
+ const summary = predicateSummary(child);
+ return child.op === 'all' || child.op === 'any' ? `(${summary})` : summary;
+ }).join(condition.op === 'all' ? ' AND ' : ' OR ');
}
if (condition.op === 'not') return `NOT (${predicateSummary(condition.condition)})`;
if (condition.op === 'exists') return `${operand(condition.value)} exists`;
diff --git a/application/v2_ui/src/lib/workflowFlowLayout.ts b/application/v2_ui/src/lib/workflowFlowLayout.ts
new file mode 100644
index 000000000..7f46b3879
--- /dev/null
+++ b/application/v2_ui/src/lib/workflowFlowLayout.ts
@@ -0,0 +1,112 @@
+// workflowFlowLayout.ts
+// Deterministic display geometry; never writes an executable workflow definition.
+
+import type { WorkflowFlowProjection, WorkflowInspectionEdge, WorkflowInspectionNode } from './workflowInspection';
+
+export interface WorkflowFlowBox {
+ id: string;
+ parentId?: string;
+ position: { x: number; y: number };
+ width: number;
+ height: number;
+ container: boolean;
+}
+
+const PADDING = 24;
+const HEADER = 92;
+const GAP = 54;
+const WIDTH = 280;
+const HEIGHT = 116;
+
+function childrenByParent(nodes: WorkflowInspectionNode[]): Map {
+ const children = new Map();
+ for (const node of nodes) {
+ if (node.parent_id === null) continue;
+ const siblings = children.get(node.parent_id) ?? [];
+ siblings.push(node);
+ children.set(node.parent_id, siblings);
+ }
+ for (const siblings of children.values()) siblings.sort((left, right) => left.order - right.order || left.id.localeCompare(right.id));
+ return children;
+}
+
+export function layoutWorkflowFlow(projection: WorkflowFlowProjection, collapsed: ReadonlySet): WorkflowFlowBox[] {
+ const children = childrenByParent(projection.nodes);
+ const byId = new Map(projection.nodes.map((node) => [node.id, node]));
+ const sizes = new Map();
+ const measure = (node: WorkflowInspectionNode): { width: number; height: number } => {
+ const descendants = collapsed.has(node.id) ? [] : children.get(node.id) ?? [];
+ const horizontal = node.kind === 'if';
+ const measured = descendants.map(measure);
+ const size = !measured.length ? { width: WIDTH, height: HEIGHT } : {
+ width: (horizontal
+ ? measured.reduce((sum, child) => sum + child.width, 0) + GAP * (measured.length - 1)
+ : Math.max(...measured.map((child) => child.width))) + PADDING * 2,
+ height: (horizontal
+ ? Math.max(...measured.map((child) => child.height))
+ : measured.reduce((sum, child) => sum + child.height, 0) + GAP * (measured.length - 1)) + HEADER + PADDING,
+ };
+ sizes.set(node.id, size);
+ return size;
+ };
+ const root = byId.get(projection.root_region_id);
+ if (!root) throw new Error('The Flow layout has no canonical root region.');
+ measure(root);
+ const boxes: WorkflowFlowBox[] = [];
+ const place = (node: WorkflowInspectionNode, x: number, y: number) => {
+ const size = sizes.get(node.id);
+ if (!size) throw new Error('The Flow layout contains an unmeasured node.');
+ const descendants = collapsed.has(node.id) ? [] : children.get(node.id) ?? [];
+ boxes.push({
+ id: node.id, ...(node.parent_id === null ? {} : { parentId: node.parent_id }),
+ position: { x, y }, ...size, container: descendants.length > 0,
+ });
+ let offset = node.kind === 'if' ? PADDING : HEADER;
+ for (const child of descendants) {
+ const childSize = sizes.get(child.id);
+ if (!childSize) throw new Error('The Flow layout contains an unmeasured region.');
+ place(child, node.kind === 'if' ? offset : (size.width - childSize.width) / 2,
+ node.kind === 'if' ? HEADER : offset);
+ offset += (node.kind === 'if' ? childSize.width : childSize.height) + GAP;
+ }
+ };
+ place(root, 0, 0);
+ return boxes;
+}
+
+export function visibleWorkflowNode(
+ nodeId: string,
+ nodes: ReadonlyMap,
+ collapsed: ReadonlySet,
+): string {
+ const node = nodes.get(nodeId);
+ if (!node) throw new Error('The Flow relationship references an unavailable node.');
+ let visibleId = nodeId;
+ let parentId = node.parent_id;
+ while (parentId !== null) {
+ const parent = nodes.get(parentId);
+ if (!parent) throw new Error('The Flow relationship has an unavailable parent.');
+ if (collapsed.has(parentId)) visibleId = parentId;
+ parentId = parent.parent_id;
+ }
+ return visibleId;
+}
+
+export function visibleWorkflowEdges(
+ projection: WorkflowFlowProjection,
+ collapsed: ReadonlySet,
+): WorkflowInspectionEdge[] {
+ const nodes = new Map(projection.nodes.map((node) => [node.id, node]));
+ const seen = new Set();
+ const edges: WorkflowInspectionEdge[] = [];
+ for (const edge of projection.edges) {
+ const source = visibleWorkflowNode(edge.source, nodes, collapsed);
+ const target = visibleWorkflowNode(edge.target, nodes, collapsed);
+ if (source === target) continue;
+ const key = JSON.stringify([source, target, edge.kind, edge.label]);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ edges.push({ ...edge, source, target });
+ }
+ return edges;
+}
diff --git a/application/v2_ui/src/lib/workflowInspection.ts b/application/v2_ui/src/lib/workflowInspection.ts
new file mode 100644
index 000000000..0fdbace8c
--- /dev/null
+++ b/application/v2_ui/src/lib/workflowInspection.ts
@@ -0,0 +1,315 @@
+// workflowInspection.ts
+// Authorized read-only projections are separate from editable workflow definitions.
+
+import { api } from './apiClient';
+import {
+ DEFAULT_FLOW_LIMITS, FLOW_MAX_NODES, MAX_LOOP_ITEMS, MAX_REPEAT_ITERATIONS,
+ isFlowBinding, isJoinExport, isRepeatState,
+} from './workflowFlow';
+import { workflowUrl, type WorkflowDefinition, type WorkflowIterationFrame, type WorkflowScope } from './workflowEditor';
+import { isRecord, sameEditorValue } from './workspaceAuthoring';
+
+export type WorkflowInspectionTarget =
+ | { kind: 'saved'; workflowId: string }
+ | { kind: 'draft'; definition: WorkflowDefinition }
+ | { kind: 'run'; workflowId: string; runId: string };
+
+export interface WorkflowInspectionSource {
+ kind: WorkflowInspectionTarget['kind'];
+ scope_type: 'personal' | 'group';
+ scope_id: string;
+ workflow_id: string | null;
+ run_id: string | null;
+ definition_revision: string;
+ snapshot_sha256?: string;
+}
+
+export const INSPECTION_NODE_KINDS = ['region', 'task', 'if', 'join', 'route', 'for_each', 'repeat_until', 'collect'] as const;
+export const INSPECTION_EDGE_KINDS = ['sequence', 'then', 'else', 'join', 'route', 'exit', 'body', 'repeat', 'complete'] as const;
+export const INSPECTION_SECTIONS = ['configuration', 'inputs', 'condition', 'outputs', 'state', 'selection'] as const;
+export type WorkflowInspectionSection = typeof INSPECTION_SECTIONS[number];
+export type InspectionJson = string | number | boolean | null | InspectionJson[] | { [key: string]: InspectionJson };
+
+export interface WorkflowInspectionNode {
+ id: string;
+ kind: typeof INSPECTION_NODE_KINDS[number];
+ label: string;
+ parent_id: string | null;
+ region_id: string | null;
+ order: number;
+ loop_ids: string[];
+ child_region_ids: string[];
+ inputs_count: number;
+ outputs_count: number;
+ has_condition: boolean;
+ task_id?: string;
+ max_items?: number;
+ max_iterations?: number;
+}
+
+export interface WorkflowInspectionEdge {
+ id: string;
+ source: string;
+ target: string;
+ kind: typeof INSPECTION_EDGE_KINDS[number];
+ label: string;
+}
+
+export interface WorkflowFlowProjection {
+ projection_version: 1;
+ definition_version: 3;
+ source: WorkflowInspectionSource;
+ name: string;
+ root_region_id: string;
+ nodes: WorkflowInspectionNode[];
+ edges: WorkflowInspectionEdge[];
+ limits: { max_executions: number; deadline_seconds: number };
+}
+
+export interface WorkflowInspectionDetails {
+ projection_version: 1;
+ source: WorkflowInspectionSource;
+ node_id: string;
+ section: WorkflowInspectionSection;
+ items: { label: string; value: InspectionJson }[];
+ total_count: number;
+ next_cursor: string | null;
+}
+
+const logicalId = (value: unknown): value is string =>
+ typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/.test(value);
+const digest = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
+const identifier = (value: unknown): value is string =>
+ typeof value === 'string' && value.trim() === value && value.length > 0 && value.length <= 256;
+const boundedText = (value: unknown, maximum = 1024): value is string => typeof value === 'string' && value.length <= maximum;
+const integer = (value: unknown, maximum: number, minimum = 0): value is number =>
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum && value <= maximum;
+const onlyKeys = (value: Record, keys: readonly string[]) => Object.keys(value).every((key) => keys.includes(key));
+
+function isSource(value: unknown): value is WorkflowInspectionSource {
+ return isRecord(value) && onlyKeys(value, [
+ 'kind', 'scope_type', 'scope_id', 'workflow_id', 'run_id', 'definition_revision', 'snapshot_sha256',
+ ]) && (value.kind === 'saved' || value.kind === 'draft' || value.kind === 'run') &&
+ (value.scope_type === 'personal' || value.scope_type === 'group') && identifier(value.scope_id) &&
+ (value.workflow_id === null || identifier(value.workflow_id)) &&
+ (value.run_id === null || identifier(value.run_id)) &&
+ (value.kind === 'draft' ? typeof value.definition_revision === 'string' &&
+ /^DRAFT:[a-f0-9]{64}$/.test(value.definition_revision) : digest(value.definition_revision)) &&
+ (value.snapshot_sha256 === undefined || digest(value.snapshot_sha256)) &&
+ (value.kind === 'run' ? identifier(value.run_id) && identifier(value.workflow_id) : value.run_id === null) &&
+ (value.kind !== 'saved' || identifier(value.workflow_id));
+}
+
+function sourceMatchesTarget(source: WorkflowInspectionSource, scope: WorkflowScope, target: WorkflowInspectionTarget): boolean {
+ return source.kind === target.kind && source.scope_type === scope.type &&
+ (scope.type !== 'group' || source.scope_id === scope.groupId) &&
+ source.workflow_id === (target.kind === 'draft' ? target.definition.id ?? null : target.workflowId) &&
+ source.run_id === (target.kind === 'run' ? target.runId : null);
+}
+
+export function inspectionSourceKey(source: WorkflowInspectionSource): string {
+ return JSON.stringify([source.kind, source.scope_type, source.scope_id, source.workflow_id,
+ source.run_id, source.definition_revision, source.snapshot_sha256 ?? null]);
+}
+
+function isInspectionNode(value: unknown): value is WorkflowInspectionNode {
+ return isRecord(value) && onlyKeys(value, [
+ 'id', 'kind', 'label', 'parent_id', 'region_id', 'order', 'loop_ids', 'child_region_ids',
+ 'inputs_count', 'outputs_count', 'has_condition', 'task_id', 'max_items', 'max_iterations',
+ ]) && logicalId(value.id) && INSPECTION_NODE_KINDS.some((kind) => kind === value.kind) && boundedText(value.label) &&
+ (value.parent_id === null || logicalId(value.parent_id)) && (value.region_id === null || logicalId(value.region_id)) &&
+ integer(value.order, FLOW_MAX_NODES * 2) &&
+ Array.isArray(value.loop_ids) && value.loop_ids.length <= 3 && value.loop_ids.every(logicalId) &&
+ new Set(value.loop_ids).size === value.loop_ids.length &&
+ Array.isArray(value.child_region_ids) && value.child_region_ids.length <= 2 && value.child_region_ids.every(logicalId) &&
+ new Set(value.child_region_ids).size === value.child_region_ids.length &&
+ integer(value.inputs_count, 100) && integer(value.outputs_count, 100) && typeof value.has_condition === 'boolean' &&
+ (value.kind === 'task' ? logicalId(value.task_id) : value.task_id === undefined) &&
+ (value.kind === 'for_each' ? integer(value.max_items, MAX_LOOP_ITEMS, 1) : value.max_items === undefined) &&
+ (value.kind === 'repeat_until' ? integer(value.max_iterations, MAX_REPEAT_ITERATIONS, 1) : value.max_iterations === undefined);
+}
+
+function isInspectionEdge(value: unknown): value is WorkflowInspectionEdge {
+ return isRecord(value) && onlyKeys(value, ['id', 'source', 'target', 'kind', 'label']) &&
+ boundedText(value.id) && value.id.length > 0 && logicalId(value.source) && logicalId(value.target) &&
+ INSPECTION_EDGE_KINDS.some((kind) => kind === value.kind) && boundedText(value.label);
+}
+
+export function parseWorkflowFlowProjection(
+ value: unknown,
+ scope: WorkflowScope,
+ target: WorkflowInspectionTarget,
+): WorkflowFlowProjection {
+ const invalid = () => new Error('The Flow projection returned unsupported or mismatched definition data.');
+ if (!isRecord(value) || !onlyKeys(value, [
+ 'projection_version', 'definition_version', 'source', 'name', 'root_region_id', 'nodes', 'edges', 'limits',
+ ]) || value.projection_version !== 1 || value.definition_version !== 3 ||
+ !isSource(value.source) || !sourceMatchesTarget(value.source, scope, target) ||
+ !boundedText(value.name) || !logicalId(value.root_region_id) ||
+ !Array.isArray(value.nodes) || value.nodes.length < 1 || value.nodes.length > FLOW_MAX_NODES ||
+ !value.nodes.every(isInspectionNode) ||
+ !Array.isArray(value.edges) || value.edges.length > FLOW_MAX_NODES * 8 || !value.edges.every(isInspectionEdge) ||
+ !isRecord(value.limits) || !onlyKeys(value.limits, ['max_executions', 'deadline_seconds']) ||
+ !integer(value.limits.max_executions, DEFAULT_FLOW_LIMITS.max_executions, 1) ||
+ !integer(value.limits.deadline_seconds, DEFAULT_FLOW_LIMITS.deadline_seconds, 1)) {
+ throw invalid();
+ }
+ const nodes = new Map(value.nodes.map((node) => [node.id, node]));
+ const root = nodes.get(value.root_region_id);
+ if (nodes.size !== value.nodes.length || new Set(value.edges.map((edge) => edge.id)).size !== value.edges.length ||
+ root?.kind !== 'region' || root.parent_id !== null ||
+ value.edges.some((edge) => !nodes.has(edge.source) || !nodes.has(edge.target))) throw invalid();
+
+ for (const node of value.nodes) {
+ if (node.id !== root.id && node.parent_id === null ||
+ node.region_id !== null && nodes.get(node.region_id)?.kind !== 'region' ||
+ node.child_region_ids.some((id) => nodes.get(id)?.kind !== 'region' || nodes.get(id)?.parent_id !== node.id)) throw invalid();
+ const ancestors: WorkflowInspectionNode[] = [];
+ let parent = node.parent_id;
+ while (parent !== null) {
+ const ancestor = nodes.get(parent);
+ if (!ancestor || ancestor.id === node.id || ancestors.some((item) => item.id === parent) || ancestors.length >= 8) throw invalid();
+ ancestors.push(ancestor);
+ parent = ancestor.parent_id;
+ }
+ if (node.id !== root.id && ancestors.at(-1)?.id !== root.id) throw invalid();
+ const loops = ancestors.filter((ancestor) => ['for_each', 'repeat_until'].includes(ancestor.kind)).reverse().map((item) => item.id);
+ if (JSON.stringify(loops) !== JSON.stringify(node.loop_ids)) throw invalid();
+ }
+ return {
+ projection_version: 1, definition_version: 3, source: value.source, name: value.name,
+ root_region_id: value.root_region_id, nodes: value.nodes, edges: value.edges,
+ limits: { max_executions: value.limits.max_executions, deadline_seconds: value.limits.deadline_seconds },
+ };
+}
+
+function isInspectionJson(value: unknown, depth = 0): value is InspectionJson {
+ if (depth > 64) return false;
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') return true;
+ if (typeof value === 'number') return Number.isFinite(value);
+ if (Array.isArray(value)) return value.every((item) => isInspectionJson(item, depth + 1));
+ return isRecord(value) && Object.values(value).every((item) => isInspectionJson(item, depth + 1));
+}
+
+export function parseWorkflowInspectionDetails(
+ value: unknown,
+ source: WorkflowInspectionSource,
+ nodeId: string,
+ section: WorkflowInspectionSection,
+): WorkflowInspectionDetails {
+ if (!isRecord(value) || !onlyKeys(value, [
+ 'projection_version', 'source', 'node_id', 'section', 'items', 'total_count', 'next_cursor',
+ ]) || value.projection_version !== 1 || !isSource(value.source) ||
+ inspectionSourceKey(value.source) !== inspectionSourceKey(source) || value.node_id !== nodeId || value.section !== section ||
+ !integer(value.total_count, Number.MAX_SAFE_INTEGER) ||
+ !(value.next_cursor === null || boundedText(value.next_cursor, 4096) && value.next_cursor.length > 0) ||
+ !Array.isArray(value.items) || value.items.length > 50 || value.items.length > value.total_count ||
+ new TextEncoder().encode(JSON.stringify(value.items)).byteLength > 256 * 1024) {
+ throw new Error('The selected Flow details returned unsupported or mismatched data.');
+ }
+ const items: WorkflowInspectionDetails['items'] = [];
+ for (const item of value.items) {
+ if (!isRecord(item) || !onlyKeys(item, ['label', 'value']) ||
+ !boundedText(item.label) || !isInspectionJson(item.value)) {
+ throw new Error('The selected Flow details contained an unsupported field.');
+ }
+ items.push({ label: item.label, value: item.value });
+ }
+ return {
+ projection_version: 1, source: value.source, node_id: nodeId, section,
+ items, total_count: value.total_count, next_cursor: value.next_cursor,
+ };
+}
+
+async function requestInspection(
+ scope: WorkflowScope,
+ target: WorkflowInspectionTarget,
+ params: Record,
+ signal?: AbortSignal,
+): Promise {
+ if (target.kind === 'draft') {
+ return api.post(workflowUrl(scope, undefined, '/flow-preview'), {
+ definition: target.definition, ...params,
+ ...(params.limit !== undefined ? { limit: Number(params.limit) } : {}),
+ }, signal);
+ }
+ const suffix = target.kind === 'run' ? `/runs/${encodeURIComponent(target.runId)}/flow` : '/flow';
+ return api.get(workflowUrl(scope, target.workflowId, suffix, new URLSearchParams(params)), signal);
+}
+
+export async function fetchWorkflowFlowProjection(
+ scope: WorkflowScope,
+ target: WorkflowInspectionTarget,
+ signal?: AbortSignal,
+): Promise {
+ return parseWorkflowFlowProjection(await requestInspection(scope, target, {}, signal), scope, target);
+}
+
+export async function fetchWorkflowInspectionDetails(
+ scope: WorkflowScope,
+ target: WorkflowInspectionTarget,
+ source: WorkflowInspectionSource,
+ nodeId: string,
+ section: WorkflowInspectionSection,
+ cursor: string | null,
+ signal?: AbortSignal,
+): Promise {
+ const params: Record = { node_id: nodeId, section, revision: source.definition_revision, limit: '50' };
+ if (cursor) params.cursor = cursor;
+ return parseWorkflowInspectionDetails(await requestInspection(scope, target, params, signal), source, nodeId, section);
+}
+
+export function inspectionNodePath(
+ node: WorkflowInspectionNode,
+ selectedPath: WorkflowIterationFrame[],
+): WorkflowIterationFrame[] | null {
+ if (node.loop_ids.some((loopId, index) => selectedPath[index]?.loop_id !== loopId)) return null;
+ return selectedPath.slice(0, node.loop_ids.length);
+}
+
+export function inspectionNodeHasExecution(node: WorkflowInspectionNode, rootRegionId: string): boolean {
+ return node.kind !== 'region' || node.id === rootRegionId;
+}
+
+export function inspectionNodeMatchesPath(
+ node: WorkflowInspectionNode,
+ selectedPath: WorkflowIterationFrame[],
+ executionPath?: WorkflowIterationFrame[],
+): boolean {
+ const path = inspectionNodePath(node, selectedPath);
+ return path !== null && sameEditorValue(path, executionPath ?? []);
+}
+
+export function workflowInspectionBindings(
+ node: WorkflowInspectionNode,
+ details: WorkflowInspectionDetails | null,
+): { sourceId: string; label: string }[] {
+ if (!details || details.node_id !== node.id) return [];
+ return details.items.flatMap(({ value }) => {
+ if (['inputs', 'outputs'].includes(details.section) && isFlowBinding(value)) {
+ return [{
+ sourceId: value.source.kind === 'node_output' ? value.source.node_id : value.source.loop_id,
+ label: `${value.name}: ${value.expected_kind}`,
+ }];
+ }
+ if (node.kind === 'join' && details.section === 'outputs' && isJoinExport(value)) {
+ return [
+ { sourceId: value.then.node_id, label: `${value.name} (Then): ${value.expected_kind}` },
+ { sourceId: value.else.node_id, label: `${value.name} (Else): ${value.expected_kind}` },
+ ];
+ }
+ if (node.kind === 'repeat_until' && details.section === 'state' && isRepeatState(value)) {
+ const body = node.child_region_ids[0];
+ return [
+ { sourceId: value.initial.kind === 'node_output' ? value.initial.node_id : value.initial.loop_id,
+ label: `${value.name} initial: ${value.output_contract.kind}` },
+ ...(body ? [{ sourceId: body, label: `${value.name} next: body export ${value.next}` }] : []),
+ ];
+ }
+ if (node.kind === 'collect' && details.section === 'configuration' && isRecord(value) &&
+ onlyKeys(value, ['loop_id', 'output']) && logicalId(value.loop_id) && typeof value.output === 'string') {
+ return [{ sourceId: value.loop_id, label: `Every frozen item: ${value.output}` }];
+ }
+ return [];
+ });
+}
diff --git a/application/v2_ui/src/pages/workspace/WorkflowsSection.tsx b/application/v2_ui/src/pages/workspace/WorkflowsSection.tsx
index d157ccf9c..8f35ba9d2 100644
--- a/application/v2_ui/src/pages/workspace/WorkflowsSection.tsx
+++ b/application/v2_ui/src/pages/workspace/WorkflowsSection.tsx
@@ -2,8 +2,9 @@
// Personal and group workflows: list, author, run, cancel, inspect history and delete.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Ban, ChevronDown, ChevronRight, Edit3, Play, Plus, Trash2, Workflow } from 'lucide-react';
+import { Ban, ChevronDown, ChevronRight, Edit3, GitBranch, Play, Plus, Trash2, Workflow } from 'lucide-react';
import { WorkflowEditorDialog } from '../../components/workflows/WorkflowEditorDialog';
+import { WorkflowFlowDialog } from '../../components/workflows/WorkflowFlowDialog';
import { WorkflowRunHistory } from '../../components/workflows/WorkflowRunHistory';
import {
ConfirmAction,
@@ -65,6 +66,7 @@ export function WorkflowsSection({
const [expandedId, setExpandedId] = useState(null);
const [historyRefreshToken, setHistoryRefreshToken] = useState(0);
const [editing, setEditing] = useState(null);
+ const [viewingFlow, setViewingFlow] = useState<{ workflowId: string; scopeKey: string } | null>(null);
const [options, setOptions] = useState(null);
const [optionsLoading, setOptionsLoading] = useState(false);
const [optionsError, setOptionsError] = useState('');
@@ -247,6 +249,12 @@ export function WorkflowsSection({
}
actions={
<>
+ {workflow.definition_version === 3 ? }
+ label={`View Flow for ${workflow.name || 'workflow'}`}
+ disabled={!workflowId}
+ onClick={() => setViewingFlow({ workflowId, scopeKey })}
+ /> : null}
}
label={running ? `${workflow.name || 'Workflow'} is running; cancel or wait before editing` : `Edit ${workflow.name || 'workflow'}`}
@@ -305,7 +313,7 @@ export function WorkflowsSection({
/>
{expanded && workflowId ? (
+ {viewingFlow?.scopeKey === scopeKey ? setViewingFlow(null)} /> : null}
{editing && options ? (
/flow` | Saved topology or a bounded node-detail section |
+| `GET /api/{user\|group}/workflows//runs//flow` | Frozen-run topology or node details |
+| `POST /api/{user\|group}/workflows/flow-preview` | Pure compiler preview of an authored draft |
+| Existing run `/executions` GET with `node_id`, `iteration_path`, `limit=1` | Exact execution lookup, not a journal scan |
+
+Every group request carries an explicit authorized `group_id`. Saved/run
+inspection uses existing reader access; preview uses authoring access. Swagger,
+Blueprint login policy, feature gates, and object-level workflow/run/scope
+checks remain in effect. Run evidence reuses current source, contributor,
+attempt, and frozen/admitted path authorization.
+
+The topology DTO contains canonical IDs, labels, kinds, nesting/order,
+connection labels, input/output counts, finite loop maxima, and run limits.
+It does not contain raw snapshots, private runner context, settings, leases,
+result-store locators, saved state values, or full schemas/instruction bodies.
+
+To inspect a node, supply its `node_id`, matching `revision`, and a `section`:
+`configuration`, `inputs`, `condition`, `outputs`, `state`, or `selection`.
+Preview uses the same selectors alongside `definition` in its POST body.
+Responses contain `items`, `total_count`, and a source-bound `next_cursor`.
+
+The UI requests at most 50 detail/history entries at a time; server detail
+pages permit 1-100 entries and impose a 240 KiB serialized response budget.
+An individually oversized detail fails rather than returning a shortened
+schema, instruction, or state contract. Pages replace one another; cursors
+are not automatically drained.
+
+Exact execution lookup validates the complete mixed path and derives identity
+server-side from the frozen definition revision. For-each item keys/indexes and
+Repeat lifetime iterations retain their existing distinct shapes. A valid
+selector with no stored execution says **No execution recorded**, not Completed
+or empty success.
+
+## Use saved and draft Flow
+
+1. In personal or group Workflows, choose **View Flow for ...** on a saved
+ version-3 workflow. This does not require opening Edit and remains available
+ while a run is active, subject to current reader access.
+2. Select a node and choose its inspection section. Instructions, contracts,
+ conditions, references, and source selections load only when requested.
+ **Source selection** describes authored selection; it does not rerun a query
+ or enumerate its current matches.
+3. Read solid arrows as control flow. Dashed arrows describe declared typed
+ connections for the selected detail page, including join exports, Repeat
+ state receipts, and Collect's frozen-item source. They are not additional
+ execution paths or loaded result values. Parallel connections have compact
+ count labels; their complete meanings remain in the relationship lists.
+4. Expand For each or Repeat to see its single body template. Use the control
+ relationship and declared-data lists to follow exact producer/boundary IDs.
+5. While editing in List, choose **Show Flow preview**. On wider screens List
+ and preview appear together; on narrow screens **Hide Flow preview** returns
+ to List. Preview starts off and does not make an unchanged draft dirty.
+
+Saving and running remain separate explicit operations outside Flow. Pan,
+zoom, fit, collapse, temporary box movement, and **Reset layout** do not save
+or affect execution.
+
+## Inspect a selected run
+
+Expand a run and choose **Show Flow for this run**. Flow replaces the execution
+list while visible, but the existing runtime panel and its authorized actions
+remain separate. It shares that panel's runtime snapshot rather than adding
+another polling loop.
+
+The viewer loads one bounded execution-metadata page, plus an explicitly
+selected exact execution. **Not loaded** means evidence has not been requested;
+it is not a guess about pending or completed work. Output validation remains
+separate from execution status, including accepted partial and incomplete data.
+An If path is marked as recorded only from a saved decision for the exact
+selected instance, never inferred from nearby completed tasks.
+
+Then/Else and loop-body regions are structural groupings, not invented
+execution records. Their configuration remains inspectable; use the enclosing
+control or contained nodes for run evidence. The root retains its real root
+execution identity.
+
+Inspect a loop boundary and use its existing frozen-item or Repeat-round pages.
+**Use item N in Flow** or **Use round N in Flow** selects the server-returned
+mixed path. Nested templates need an exact enclosing instance before runtime
+inspection. Attempts, result excerpts, complete records, contributors, and
+before/after Repeat state use the same read-only inspector as List history.
+
+The run-wide retained Repeat observation names its actual execution; it is not
+an aggregate for every instance of that template. The actual runtime gate and
+its choices take precedence over retained counters. A cancel-only gate never
+becomes a continuation action in Flow.
+
+Changing source, revision, run, or instance discards incompatible observations.
+Cancelled/stale requests cannot repopulate a newer selection. Access-loss
+responses remove cached graphs, observations, state, and result inspection.
+
+## Accessibility and mobile behavior
+
+**Structure list** and **Flow diagram** share the same projection and inspector.
+The structure list is the initial narrow-screen view. Diagram keyboard
+navigation follows logical order: Up/Down, Home/End, Left to the parent, Right
+to enter/expand, and Enter to select. **Inspect selected node** transfers focus
+to the labeled inspector; **Return to selected node** returns it.
+
+Collapsing a selected descendant recovers focus at the visible owning
+boundary. Zoom and pan have buttons, and temporary box movement has a keyboard
+alternative to dragging. Touch views do not capture background drag or pinch
+zoom, leaving page scrolling and browser zoom available.
+
+Labels, relationship lists, and line styles supplement visual geometry.
+Theme tokens support light/dark mode, and programmatic view movement avoids
+animation, including with reduced motion.
+
+## Preserved M4 contracts
+
+For each remains serial, with the 500-actual-input administrator default and
+1-5,000 supported range. Repeat retains an explicit authored automatic batch,
+its separate administrator default of 25 and range of 1-1,000, post-body typed
+Until, exhaustion pause, and explicit same-sized continuation.
+
+Only batch usage resets after continuation. Lifetime round identity, the
+original admission budget, and elapsed deadline do not reset; lifetime round
+1,001 is not confused with the per-batch ceiling. Existing 5,000-admission and
+86,400-second bounds include waits. A cumulative run-token/spend cap remains
+deferred.
+
+Typed state, source authorization, locally metered loops/reports, native
+Analyze identities, exact Collect order/lineage, and shared `exact_records_v1`
+JSON export are unchanged. Publication still distinguishes submission,
+approval, and indexed readiness. Refreshing inspection displays saved
+observations; it cannot publish, reconcile readiness, or rerun Analyze.
+
+## Coverage and limitations
+
+Offline coverage is in:
+
+- `functional_tests/test_workflow_flow_inspection.py`: real compiler projection,
+ allowlists, bounded details, frozen snapshots, exact lookup, and lifetime paths.
+- `functional_tests/route_tests/test_workflow_flow_inspection_policy.py`:
+ personal/group reader and author gates, scope isolation, and safe failures.
+- `functional_tests/test_workflow_flow_layout.py`: deterministic bounded layout,
+ source guards, hierarchy, and collapsed-template behavior.
+- `functional_tests/test_workflow_execution_inspection_client.js`: exact lookup
+ client and shared inspection contracts.
+- `functional_tests/test_workflow_flow_semantics.js`: real execution boundaries,
+ mixed-frame equality, and unambiguous nested condition summaries.
+- `functional_tests/test_workflow_flow_assets.py`: pinned local dependencies,
+ copied license notices, and static import boundaries.
+- `ui_tests/test_v2_workflow_flow_inspection.py`: the actual local V2 bundle
+ against closed fictional APIs, including interaction and request isolation.
+
+The structural bound remains 256 canonical IDs, region depth four, and three
+mixed loop frames. Dense definitions may require zoom or the structure list;
+they never expand into thousands of runtime boxes. Geometry is not retained
+after closing the viewing session.
+
+These fixtures do not establish live Azure performance or production
+acceptance. No deployment, real publication, production workflow invocation,
+permission change, or merge is part of this slice.
diff --git a/docs/explanation/features/WORKFLOW_STRUCTURED_CONTROL_FLOW.md b/docs/explanation/features/WORKFLOW_STRUCTURED_CONTROL_FLOW.md
index c1c3d52e0..a8307e30c 100644
--- a/docs/explanation/features/WORKFLOW_STRUCTURED_CONTROL_FLOW.md
+++ b/docs/explanation/features/WORKFLOW_STRUCTURED_CONTROL_FLOW.md
@@ -2,7 +2,7 @@
Implemented in version: **0.261.116**
-Updated in version: **0.261.120**.
+Updated in version: **0.261.121**.
Application version tracking: `application/single_app/config.py`.
@@ -22,16 +22,18 @@ This page describes the M4A foundation. Version **0.261.117** adds
definition version and journal. Version **0.261.119** adds
[saved-record JSON publication](WORKFLOW_SAVED_OUTPUT_PUBLICATION.md).
Version **0.261.120** adds [Repeat until](WORKFLOW_REPEAT_UNTIL.md), with saved
-typed state and explicit manual grants after finite automatic batches. M5A
-read-only Flow and M5B accessible visual authoring remain separate subsequent
-milestones. M4A itself did not admit loops.
+typed state and explicit manual grants after finite automatic batches.
+Version **0.261.121** adds [M5A read-only Flow inspection](WORKFLOW_FLOW_INSPECTION.md)
+over the same compiler and runtime. M5B accessible visual authoring remains
+separate. M4A itself did not admit loops.
## Dependencies and compatibility
Structured control flow uses the existing workflow runner, durable execution
lease, private result store, source-authorized readers, native Analyze adapter,
-and artifact publication service. No second scheduler, Cosmos container, or
-external browser library is introduced.
+and artifact publication service. There is no second scheduler or Cosmos
+container. The later read-only Flow renderer uses locally bundled React Flow;
+it does not introduce a stored executable graph or change the runtime model.
Definition version **3** is an explicit opt-in and requires durable execution.
Existing version-1 and version-2 definitions keep their previous behavior.
@@ -219,6 +221,25 @@ whether every authored branch ran. Accepted partial work remains
`completed_partial`. Exhausted limits and missing required results do not
become Completed.
+### Read-only Flow inspection
+
+In **0.261.121**, saved definitions, live List draft previews, and selected
+runs' verified frozen definitions have separate Flow views. Run evidence is
+never overlaid on a newer saved definition or an unsaved draft. Exact
+node-and-mixed-path lookup uses the frozen revision rather than guessing the
+latest task with a matching name.
+
+The layout follows normalized region order, preserves explicit joins and
+boundary IDs, and represents each loop body once. Selected-page typed bindings
+are distinct from control connections. Shared inspectors retain exact attempts,
+frozen items, Repeat state, complete records, and publication observations.
+
+Layout is temporary viewing state, excluded from executable definitions and
+revision hashes. Viewing, expanding, moving, and refreshing cannot approve,
+resume, continue, publish, or restart a run. See
+[the inspection contract](WORKFLOW_FLOW_INSPECTION.md) for APIs, bounds, source
+isolation, accessibility, and offline coverage.
+
## Publication boundary
Existing publication tasks use the existing artifact publication service and
diff --git a/docs/guides/create-a-workflow.md b/docs/guides/create-a-workflow.md
index cfe5861c6..a259678cc 100644
--- a/docs/guides/create-a-workflow.md
+++ b/docs/guides/create-a-workflow.md
@@ -73,12 +73,42 @@ Declare promised deliverables under **Final outputs**. This prevents a run from
reporting completion when a selected path did not produce the required result.
Structured definitions require durable execution and preserve their choices
across waits and restarts. For each and Collect are added in **0.261.117** below.
-Repeat until is added in **0.261.120** below. M5A read-only Flow and M5B visual
-authoring remain separate later milestones.
+Repeat until is added in **0.261.120** below. Version **0.261.121** adds the
+read-only Flow inspection described next. Direct visual authoring remains a
+separate later milestone; executable edits still happen in List.
See [Structured workflow control flow](../explanation/features/WORKFLOW_STRUCTURED_CONTROL_FLOW.md)
for condition semantics, execution identity, limits, and compatibility.
+## Preview the structure without changing execution
+
+In **0.261.121**, choose **View Flow for ...** beside a saved structured
+workflow to see its branches, joins, routes, and single loop templates.
+This is independent of Edit, so an authorized reader can inspect a saved
+definition while it has an active run.
+
+While authoring, choose **Show Flow preview** to check List changes before
+saving. The preview starts off. It appears alongside List on a wide screen;
+on a narrow screen, **Hide Flow preview** returns to List. **Unsaved draft**
+means the picture reflects the editor, not a saved workflow or a past run.
+Invalid structural edits keep your draft and replace the outdated picture
+with an error. A valid diagram is not permission to execute.
+
+Select a node to read its configuration, condition, contracts, or source
+selection. Solid arrows describe execution order. Dashed arrows describe
+declared data connections for the selected detail page, not extra execution
+paths. Expand a loop to see its template once, not one box per item or round.
+
+Use **Structure list** for a textual view of the same definition. In the
+diagram, arrow keys move focus, Enter selects, and **Inspect selected node**
+opens the inspection focus target. **Return to selected node** takes focus
+back. Pan, zoom, fit, collapse, and moving a box are temporary viewing choices:
+they do not make the draft dirty, save it, invalidate approval, or restart work.
+
+Saved Flow has no historical run coloring. To see what a run actually used,
+open its [frozen-definition Flow]({{ '/guides/trigger-a-workflow/' | relative_url }}#inspect-a-runs-frozen-flow)
+rather than comparing it with today's edited definition.
+
## Process a frozen collection
In **0.261.117**, add a **For each** block to apply its body to selected
diff --git a/docs/guides/trigger-a-workflow.md b/docs/guides/trigger-a-workflow.md
index 5c79ed2fc..5bf56a464 100644
--- a/docs/guides/trigger-a-workflow.md
+++ b/docs/guides/trigger-a-workflow.md
@@ -90,6 +90,41 @@ or deadline limit cannot be cleared by a normal Resume, and changing source
data cannot silently choose a different branch. See
[Structured workflow control flow](../explanation/features/WORKFLOW_STRUCTURED_CONTROL_FLOW.md).
+## Inspect a run's frozen Flow
+
+In **0.261.121**, expand a version-3 run in V2 history and choose **Show Flow
+for this run**. **Run's frozen definition** is the exact configuration admitted
+for that run, even if someone later edited the saved workflow or reused a node
+name. Missing snapshots report an error instead of showing today's definition.
+**Hide Flow for this run** returns to the execution list.
+
+Select a node to inspect its exact execution and attempts. One bounded
+metadata page is loaded; **Not loaded** does not mean Pending or Completed.
+**No execution recorded** is an explicit lookup result, not an empty successful
+output. Configuration sections, result excerpts, full records, and contributor
+pages remain separate requests.
+
+Then/Else and body regions group the structure rather than having separate
+execution records. Inspect their enclosing control or a contained task for
+run evidence; a recorded If-path label comes from that exact instance's saved
+decision.
+
+Loops show one template. Inspect the enclosing loop's frozen item or Repeat
+round pages, then choose **Use item N in Flow** or **Use round N in Flow**.
+That selects the exact mixed instance path, including its outer item/round.
+An unselected template cannot stand in for its latest execution.
+
+The runtime panel keeps its own authorized approval, recovery, Resume, and
+continuation controls outside Flow. Its actual gate overrides retained Repeat
+counters. Inspect execution status, output validation, and saved publication
+observations separately: a submitted file is not necessarily approved or
+indexed-ready, and graph refresh does not perform a new readiness check.
+
+On narrow screens, **Structure list** is the initial read-only presentation;
+**Flow diagram** enables the optional picture. Both use the same inspector.
+For configuration without run evidence, use the
+[saved or unsaved-draft view]({{ '/guides/create-a-workflow/' | relative_url }}#preview-the-structure-without-changing-execution).
+
## Inspect loop progress
For each runs introduced in **0.261.117** retain their frozen item count, order,
diff --git a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py
index a22c18500..14013b615 100644
--- a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py
+++ b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py
@@ -2,7 +2,7 @@
# test_route_blueprint_policy_inventory.py
"""
Functional test for route blueprint policy inventory.
-Version: 0.261.113
+Version: 0.261.121
Implemented in: 0.242.069
Plan editor policy coverage: 0.261.102
@@ -121,6 +121,12 @@
}
SENSITIVE_ROUTE_POLICIES = {
+ ("route_backend_workflows.py", "get_user_workflow_flow"): ("login_required", "user_required", "workflow_user_required"),
+ ("route_backend_workflows.py", "get_group_workflow_flow"): ("login_required", "user_required"),
+ ("route_backend_workflows.py", "get_user_workflow_run_flow"): ("login_required", "user_required", "workflow_user_required"),
+ ("route_backend_workflows.py", "get_group_workflow_run_flow"): ("login_required", "user_required"),
+ ("route_backend_workflows.py", "preview_user_workflow_flow"): ("login_required", "user_required", "workflow_user_required"),
+ ("route_backend_workflows.py", "preview_group_workflow_flow"): ("login_required", "user_required"),
("route_backend_analysis_results.py", "get_saved_analysis_result"): ("login_required", "user_required"),
("app.py", "session_heartbeat"): ("login_required",),
("app.py", "list_semantic_kernel_plugins"): ("login_required", "admin_required"),
diff --git a/functional_tests/route_tests/test_workflow_flow_inspection_policy.py b/functional_tests/route_tests/test_workflow_flow_inspection_policy.py
new file mode 100644
index 000000000..9db0befb8
--- /dev/null
+++ b/functional_tests/route_tests/test_workflow_flow_inspection_policy.py
@@ -0,0 +1,468 @@
+# test_workflow_flow_inspection_policy.py
+"""
+Offline route policy tests for saved, draft and frozen-run Flow inspection.
+Version: 0.261.121
+Implemented in: 0.261.121
+
+Real route helpers, compiler and snapshot readers use closed Flask/WSGI fixtures
+and fictional stores. No application clients, credentials or live work are used.
+"""
+
+import ast
+import copy
+import json
+import logging
+import sys
+from functools import wraps
+from pathlib import Path
+
+import pytest
+from azure.core.exceptions import AzureError
+from azure.cosmos.exceptions import CosmosResourceNotFoundError
+from flask import Blueprint, Flask, jsonify, request, session
+from werkzeug.test import Client
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "application" / "single_app"))
+sys.path.insert(0, str(ROOT / "functional_tests"))
+
+# Production imports follow the isolated worktree import setup.
+import functions_workflow_inspection as inspection
+from functions_analysis_access import AnalysisResultUnavailable
+from functions_workflow_definitions import WorkflowDefinitionConflict, WorkflowDefinitionError
+from functions_workflow_execution_history import workflow_execution_history, workflow_execution_result_page
+from functions_workflow_identity import workflow_execution_id
+from functions_workflow_inspection import (
+ WorkflowFlowDetailTooLarge, WorkflowFlowUnsupported, authorize_workflow_flow_sources, preview_workflow_flow,
+ workflow_flow_inspection, workflow_run_flow_inspection,
+)
+from functions_workflow_journal import journal_record_id
+from functions_workflow_node_results import WorkflowRecordPageTooLarge
+from functions_workflow_result_store import WorkflowResultStorageUnavailableError, WorkflowResultStore
+from functions_workflow_runtime_store import CONTROL_ID, RuntimeUnavailable, WorkflowRuntimeConflict, WorkflowRuntimeStore
+from test_workflow_structured_flow import create_structured_runtime, definition, run_flow
+
+
+ROUTES = ROOT / "application" / "single_app" / "route_backend_workflows.py"
+FLOW_FUNCTIONS = {
+ "get_user_workflow_flow", "get_group_workflow_flow",
+ "get_user_workflow_run_flow", "get_group_workflow_run_flow",
+ "preview_user_workflow_flow", "preview_group_workflow_flow",
+}
+
+
+def test_all_six_flow_routes_keep_existing_blueprint_swagger_and_feature_policies():
+ tree = ast.parse(ROUTES.read_text(encoding="utf-8"))
+ registrar = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "register_route_backend_workflows")
+ found = set()
+ for function in registrar.body:
+ if not isinstance(function, ast.FunctionDef) or function.name not in FLOW_FUNCTIONS:
+ continue
+ route = function.decorator_list[0]
+ path = route.args[0].value
+ decorators = [ast.unparse(value) for value in function.decorator_list]
+ assert ast.unparse(route.func) == "bp.route"
+ assert decorators[1] == "swagger_route(security=get_auth_security())"
+ assert "login_required" in decorators and "user_required" in decorators
+ if "/user/" in path:
+ assert "workflow_user_required" in decorators
+ assert "enabled_required('allow_user_workflows')" in decorators
+ else:
+ assert "enabled_required('enable_group_workspaces')" in decorators
+ assert "enabled_required('allow_group_workflows')" in decorators
+ methods = next(keyword.value for keyword in route.keywords if keyword.arg == "methods")
+ assert ast.literal_eval(methods) == (["POST"] if "preview" in function.name else ["GET"])
+ found.add(function.name)
+ assert found == FLOW_FUNCTIONS
+
+
+@pytest.fixture
+def api(monkeypatch):
+ state = {
+ "user": "owner", "login": True, "user_role": True, "workflow_role": True,
+ "group_role": "User", "source": True, "own": True, "reads": [],
+ "settings": {"allow_user_workflows": True, "allow_group_workflows": True, "enable_group_workspaces": True},
+ }
+ workspaces = {}
+ for group_id in (None, "fictional-group"):
+ workflow = definition()
+ if group_id:
+ workflow["group_id"] = group_id
+ workflow["reference_inputs"] = [{
+ "id": "context", "name": "context", "document_id": "fictional-document",
+ "scope_type": "group" if group_id else "personal", "scope_id": group_id or "owner",
+ }]
+ workflow, store, container, clock = create_structured_runtime(workflow, monkeypatch)
+ run_flow(workflow, store)
+ run = {"id": "run", "workflow_id": "workflow", "user_id": "owner", "durable_execution": True}
+ if group_id:
+ run["group_id"] = group_id
+ workspaces[group_id] = {"workflow": workflow, "run": run, "container": container, "clock": clock}
+
+ def result_store(workflow):
+ return WorkflowResultStore(workspaces[workflow.get("group_id")]["container"])
+
+ def runtime_store(workflow, run_id):
+ state["reads"].append(("snapshot", workflow.get("group_id"), run_id))
+ workspace = workspaces[workflow.get("group_id")]
+ return WorkflowRuntimeStore(workspace["container"], workflow, run_id, clock=workspace["clock"])
+
+ monkeypatch.setattr("functions_workflow_result_store._configured_store", result_store)
+ monkeypatch.setattr(inspection, "workflow_runtime_store", runtime_store)
+ monkeypatch.setattr("functions_workflow_execution_history.workflow_runtime_store", runtime_store)
+
+ def resolve_document(**arguments):
+ assert arguments["include_content"] is False
+ state["reads"].append(("source", arguments["document_id"]))
+ if not state["source"]:
+ raise PermissionError("Fictional revoked source")
+ scope = arguments["doc_scope"]
+ return {
+ "scope": scope, "group_id": "fictional-group" if scope == "group" else None,
+ "document": {"id": arguments["document_id"], "user_id": "owner"},
+ }
+
+ def no_content(*args, **kwargs):
+ raise AssertionError("Flow inspection must not load document content.")
+
+ monkeypatch.setattr("functions_workflow_bindings._source_helpers", lambda: (resolve_document, no_content))
+
+ def read(group_id, kind, identifier, user_id=None):
+ state["reads"].append((kind, group_id, identifier))
+ if group_id not in workspaces or group_id is None and (not state["own"] or user_id != "owner"):
+ return None
+ item = workspaces[group_id][kind]
+ return copy.deepcopy(item) if item["id"] == identifier else None
+
+ def assert_role(user_id, group_id, *, allowed_roles):
+ assert user_id == state["user"]
+ if group_id != "fictional-group" or state["group_role"] not in allowed_roles:
+ raise PermissionError("Fictional current membership denied")
+
+ def guard(allowed, code=403):
+ def decorate(function):
+ @wraps(function)
+ def guarded(*args, **kwargs):
+ if not allowed():
+ return jsonify({"error": "Not allowed"}), code
+ return function(*args, **kwargs)
+ return guarded
+ return decorate
+
+ namespace = {
+ "json": json, "jsonify": jsonify, "request": request, "session": session, "logging": logging,
+ "get_current_user_id": lambda: state["user"], "get_settings": lambda: state["settings"],
+ "is_user_workflows_enabled_for_user": lambda settings, **kwargs: settings["allow_user_workflows"] and state["workflow_role"],
+ "is_group_workflows_enabled_for_group": lambda settings, group_id: group_id == "fictional-group" and settings["allow_group_workflows"],
+ "get_group_workflow_management_roles": lambda settings: ("Owner", "Admin", "DocumentManager"),
+ "GROUP_WORKFLOW_MEMBER_ROLES": ("Owner", "Admin", "DocumentManager", "User"),
+ "assert_group_role": assert_role,
+ "require_active_group": no_content,
+ "get_personal_workflow": lambda user, key: read(None, "workflow", key, user),
+ "get_group_workflow": lambda group, key: read(group, "workflow", key),
+ "get_personal_workflow_run": lambda user, key: read(None, "run", key, user),
+ "get_group_workflow_run": lambda group, key: read(group, "run", key),
+ "authorize_workflow_flow_sources": authorize_workflow_flow_sources,
+ "workflow_flow_inspection": workflow_flow_inspection,
+ "workflow_run_flow_inspection": workflow_run_flow_inspection,
+ "preview_workflow_flow": preview_workflow_flow,
+ "workflow_execution_history": workflow_execution_history,
+ "workflow_execution_result_page": workflow_execution_result_page,
+ "WorkflowFlowDetailTooLarge": WorkflowFlowDetailTooLarge, "WorkflowFlowUnsupported": WorkflowFlowUnsupported,
+ "WorkflowDefinitionConflict": WorkflowDefinitionConflict, "WorkflowDefinitionError": WorkflowDefinitionError,
+ "WorkflowRuntimeConflict": WorkflowRuntimeConflict, "RuntimeUnavailable": RuntimeUnavailable,
+ "WorkflowResultStorageUnavailableError": WorkflowResultStorageUnavailableError,
+ "WorkflowRecordPageTooLarge": WorkflowRecordPageTooLarge, "AnalysisResultUnavailable": AnalysisResultUnavailable,
+ "CosmosResourceNotFoundError": CosmosResourceNotFoundError, "AzureError": AzureError,
+ "log_event": lambda *args, **kwargs: None,
+ "swagger_route": lambda **kwargs: lambda function: function, "get_auth_security": lambda: [],
+ "login_required": guard(lambda: state["login"], 401),
+ "user_required": guard(lambda: state["user_role"]),
+ "workflow_user_required": guard(lambda: state["workflow_role"]),
+ "enabled_required": lambda feature: guard(lambda: state["settings"][feature]),
+ }
+ tree = ast.parse(ROUTES.read_text(encoding="utf-8"))
+ helpers = {
+ "_normalize_identifier", "_assert_personal_workflow_draft_access", "_assert_group_workflow_feature_enabled",
+ "_resolve_active_group_for_workflows", "_resolve_group_workflow_request_group",
+ "_resolve_active_group_for_workflow_management", "_assert_workflow_flow_reader_scope",
+ "_workflow_flow_response", "_workflow_execution_history_response", "_workflow_inspection_cache_response",
+ }
+ functions = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in helpers]
+ registrar = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "register_route_backend_workflows")
+ registrar.body = [
+ node for node in registrar.body
+ if isinstance(node, ast.FunctionDef) and (
+ node.name in FLOW_FUNCTIONS
+ or node.name.startswith("get_") and any(
+ isinstance(call, ast.Call) and isinstance(call.func, ast.Name)
+ and call.func.id in {"_workflow_execution_history_response", "_workflow_runtime_response"}
+ for call in ast.walk(node)
+ )
+ )
+ or isinstance(node, ast.Expr) and ast.unparse(node) == "bp.after_request(_workflow_inspection_cache_response)"
+ ]
+ exec(compile(ast.Module(body=[*functions, registrar], type_ignores=[]), str(ROUTES), "exec"), namespace)
+ app = Flask("workflow-flow")
+ app.config.update(TESTING=True, SECRET_KEY="fictional-closed-test")
+ blueprint = Blueprint("backend_workflows", __name__)
+ blueprint.before_request(lambda: None if state["login"] else (jsonify({"error": "Login required"}), 401))
+ namespace["register_route_backend_workflows"](blueprint)
+ app.register_blueprint(blueprint)
+ return Client(app, app.response_class), state, workspaces
+
+
+def test_saved_draft_and_run_routes_return_source_bound_shapes(api):
+ client, state, _ = api
+ saved = client.get("/api/user/workflows/workflow/flow")
+ assert saved.status_code == 200 and saved.json["source"]["kind"] == "saved"
+ assert saved.cache_control.no_store and saved.cache_control.private
+ run = client.get("/api/user/workflows/workflow/runs/run/flow")
+ assert run.status_code == 200 and run.json["source"]["kind"] == "run"
+ assert run.cache_control.no_store and run.cache_control.private
+ assert run.json["source"]["run_id"] == "run" and run.json["source"]["snapshot_sha256"]
+ detail = client.get("/api/user/workflows/workflow/flow", query_string={
+ "node_id": "report-node", "section": "inputs", "revision": saved.json["source"]["definition_revision"],
+ })
+ assert detail.status_code == 200 and detail.json["items"][0]["value"]["source"]["node_id"] == "joined"
+ assert detail.cache_control.no_store and detail.cache_control.private
+ state["reads"].clear()
+ draft = {**definition(), "id": "foreign-workflow", "user_id": "foreign", "group_id": "foreign-group"}
+ preview = client.post("/api/user/workflows/flow-preview", json={"definition": draft})
+ assert preview.status_code == 200 and preview.json["source"]["kind"] == "draft"
+ assert preview.cache_control.no_store and preview.cache_control.private
+ assert preview.json["source"]["scope_id"] == "owner" and state["reads"] == []
+ assert "definition_revision" not in draft
+
+
+def test_group_reader_and_author_permissions_are_distinct_and_current(api):
+ client, state, _ = api
+ state["user"] = "member"
+ query = {"group_id": "fictional-group"}
+ saved = client.get("/api/group/workflows/workflow/flow", query_string=query)
+ run = client.get("/api/group/workflows/workflow/runs/run/flow", query_string=query)
+ assert saved.status_code == run.status_code == 200
+ assert saved.json["source"]["scope_id"] == "fictional-group"
+ assert client.post("/api/group/workflows/flow-preview", query_string=query, json={"definition": definition()}).status_code == 403
+ state["group_role"] = "Owner"
+ preview = client.post("/api/group/workflows/flow-preview", query_string=query, json={"definition": definition()})
+ assert preview.status_code == 200 and preview.json["source"]["scope_type"] == "group"
+ state["group_role"] = None
+ assert client.get("/api/group/workflows/workflow/flow", query_string=query).status_code == 403
+ assert client.get("/api/group/workflows/workflow/runs/run/flow", query_string=query).status_code == 403
+ assert client.get("/api/group/workflows/workflow/flow").status_code == 400
+
+
+def test_workflow_run_and_scope_ids_are_not_authorization(api):
+ client, state, workspaces = api
+ assert client.get("/api/user/workflows/foreign/flow").status_code == 404
+ assert client.get("/api/user/workflows/workflow/runs/foreign/flow").status_code == 404
+ assert client.get("/api/group/workflows/workflow/flow?group_id=foreign").status_code == 403
+ workspaces[None]["workflow"]["user_id"] = "foreign"
+ assert client.get("/api/user/workflows/workflow/flow").status_code == 404
+ workspaces[None]["workflow"]["user_id"] = "owner"
+ workspaces[None]["run"]["workflow_id"] = "foreign"
+ assert client.get("/api/user/workflows/workflow/runs/run/flow").status_code == 404
+ workspaces[None]["run"]["workflow_id"] = "workflow"
+ workspaces[None]["run"]["user_id"] = "foreign"
+ assert client.get("/api/user/workflows/workflow/runs/run/flow").status_code == 404
+ workspaces["fictional-group"]["workflow"]["group_id"] = "foreign"
+ assert client.get("/api/group/workflows/workflow/flow?group_id=fictional-group").status_code == 404
+ state["own"] = False
+ assert client.get("/api/user/workflows/workflow/flow").status_code == 404
+
+
+@pytest.mark.parametrize("scope", ["user", "group"])
+def test_saved_and_frozen_source_revocation_clears_topology_and_details(api, scope):
+ client, state, _ = api
+ if scope == "group":
+ state["user"] = "member"
+ query = {"group_id": "fictional-group"} if scope == "group" else {}
+ base = f"/api/{scope}/workflows/workflow"
+ paths = [f"{base}/flow", f"{base}/runs/run/flow"]
+ sources = {}
+ for path in paths:
+ response = client.get(path, query_string=query)
+ assert response.status_code == 200 and response.cache_control.no_store
+ sources[path] = response.json["source"]
+ assert ("source", "fictional-document") in state["reads"]
+ state["source"] = False
+ for path in paths:
+ for selectors in ({}, {
+ "node_id": "report-node", "section": "configuration",
+ "revision": sources[path]["definition_revision"],
+ }):
+ state["reads"].clear()
+ response = client.get(path, query_string={**query, **selectors})
+ assert response.status_code == 403 and "nodes" not in response.json and "items" not in response.json
+ assert response.cache_control.no_store and response.cache_control.private
+ assert ("source", "fictional-document") in state["reads"]
+
+
+def test_detail_revision_bounds_conflicts_and_unsupported_snapshots(api, monkeypatch):
+ client, _, workspaces = api
+ base = "/api/user/workflows/workflow/flow"
+ saved = client.get(base).json
+ selectors = {"node_id": "report-node", "section": "configuration", "revision": saved["source"]["definition_revision"]}
+ assert client.get(base, query_string={**selectors, "limit": 101}).status_code == 400
+ assert client.get(base, query_string={**selectors, "cursor": "forged"}).status_code == 400
+ assert client.get(base, query_string={"node_id": "report-node"}).status_code == 400
+ assert client.get(base, query_string={**selectors, "revision": "stale"}).status_code == 409
+ assert client.get(base, query_string={**selectors, "node_id": "foreign"}).status_code == 404
+ workspaces[None]["workflow"]["tasks"][0]["instructions"] = "New live instructions."
+ assert client.get(base, query_string=selectors).status_code == 409
+ run = client.get("/api/user/workflows/workflow/runs/run/flow")
+ assert run.status_code == 200 and run.json["source"]["definition_revision"] == saved["source"]["definition_revision"]
+ monkeypatch.setattr(inspection, "FLOW_DETAIL_MAX_BYTES", 100)
+ response = client.get("/api/user/workflows/workflow/runs/run/flow", query_string=selectors)
+ assert response.status_code == 413 and "items" not in response.json
+ workspaces[None]["container"].items["run", CONTROL_ID]["schema_version"] = 1
+ assert client.get("/api/user/workflows/workflow/runs/run/flow").status_code == 409
+
+
+def test_unsupported_saved_versions_do_not_read_sources_or_fall_back(api):
+ client, state, workspaces = api
+ for version in (1, 2, 4, True, "3"):
+ workspaces[None]["workflow"]["definition_version"] = version
+ state["reads"].clear()
+ response = client.get("/api/user/workflows/workflow/flow")
+ assert response.status_code == 409 and response.json["code"] == "workflow_flow_unsupported"
+ assert response.cache_control.no_store and response.cache_control.private
+ assert all(read[0] not in {"source", "snapshot"} for read in state["reads"])
+
+
+@pytest.mark.parametrize("body", [
+ None, [], {}, {"definition": None}, {"definition": definition(), "save": True},
+ {"definition": definition(), "node_id": "report-node", "section": "configuration", "revision": "stale"},
+])
+def test_invalid_preview_never_uses_stored_draft_id(api, body):
+ client, state, _ = api
+ state["reads"].clear()
+ assert client.post("/api/user/workflows/flow-preview", json=body).status_code in {400, 409}
+ assert state["reads"] == []
+
+
+def test_exact_selector_routes_use_frozen_identity_and_reject_conflicts(api, monkeypatch):
+ client, state, workspaces = api
+ base = "/api/user/workflows/workflow/runs/run/executions"
+ history = client.get(base, query_string={"limit": 1})
+ assert history.status_code == 200 and history.cache_control.no_store and history.cache_control.private
+ before = copy.deepcopy(workspaces[None]["workflow"])
+ workspaces[None]["workflow"]["tasks"][0]["instructions"] = "Updated live."
+ monkeypatch.setattr(workspaces[None]["container"], "query_items", lambda **kwargs: pytest.fail("Exact read scanned history"))
+ query = {"node_id": "report-node", "iteration_path": "[]"}
+ response = client.get(base, query_string=query)
+ assert response.status_code == 200 and response.json["total_count"] == 1
+ assert response.cache_control.no_store and response.cache_control.private
+ assert response.json["executions"][0]["execution_id"] == workflow_execution_id(before, "run", "report-node")
+ for invalid in (
+ {"node_id": "report-node"}, {"iteration_path": "[]"},
+ {**query, "cursor": "anything"}, {**query, "attempt": 2},
+ {**query, "iteration_path": "null"}, {**query, "iteration_path": "[broken"},
+ {**query, "iteration_path": "[{}]"}, {**query, "node_id": "positive"},
+ {**query, "limit": 101},
+ ):
+ response = client.get(base, query_string=invalid)
+ assert response.status_code == 400 and response.cache_control.no_store
+ state["own"] = False
+ assert client.get(base, query_string=query).status_code == 404
+
+
+def test_group_exact_selector_retains_current_reader_access(api):
+ client, state, workspaces = api
+ state["user"] = "member"
+ base = "/api/group/workflows/workflow/runs/run/executions"
+ query = {"group_id": "fictional-group", "node_id": "report-node", "iteration_path": "[]"}
+ response = client.get(base, query_string=query)
+ assert response.status_code == 200
+ assert response.json["executions"][0]["execution_id"] == workflow_execution_id(
+ workspaces["fictional-group"]["workflow"], "run", "report-node",
+ )
+ assert client.get(base, query_string={**query, "group_id": "foreign"}).status_code == 403
+ state["group_role"] = None
+ assert client.get(base, query_string=query).status_code == 403
+
+
+@pytest.mark.parametrize("scope", ["user", "group"])
+def test_missing_exact_record_is_metadata_not_a_missing_resource_or_result(api, monkeypatch, scope):
+ client, state, workspaces = api
+ group_id = "fictional-group" if scope == "group" else None
+ workspace = workspaces[group_id]
+ if group_id:
+ state["user"] = "member"
+ query = {"group_id": group_id} if group_id else {}
+ base = f"/api/{scope}/workflows/workflow/runs/run/executions"
+ for node_id in ("root", "report-node"):
+ execution_id = workflow_execution_id(workspace["workflow"], "run", node_id, [])
+ workspace["container"].items.pop(("run", journal_record_id("execution", execution_id)), None)
+ workspace["workflow"]["tasks"][0]["instructions"] = "Changed after this frozen run."
+ workspace["workflow"]["reference_inputs"] = []
+ monkeypatch.setattr(workspace["container"], "query_items", lambda **kwargs: pytest.fail("Exact lookup scanned history"))
+ for node_id in ("root", "report-node"):
+ response = client.get(base, query_string={**query, "node_id": node_id, "iteration_path": "[]"})
+ assert response.status_code == 200
+ assert response.json == {"executions": [], "next_cursor": None, "total_count": 0}
+ assert response.cache_control.no_store and response.cache_control.private
+ assert ("source", "fictional-document") in state["reads"]
+ state["source"] = False
+ selectors = {**query, "node_id": "report-node", "iteration_path": "[]"}
+ response = client.get(base, query_string=selectors)
+ assert response.status_code == 403 and "executions" not in response.json
+ state["source"] = True
+ assert client.get(base.replace("/runs/run/", "/runs/foreign/"), query_string=selectors).status_code == 404
+ assert client.get(base, query_string={**selectors, "node_id": "foreign-node"}).status_code == 400
+ workspace["container"].items = {
+ key: value for key, value in workspace["container"].items.items() if key[1] == CONTROL_ID
+ }
+ response = client.get(base, query_string=selectors)
+ assert response.status_code == 404 and "executions" not in response.json
+
+
+def test_all_flow_routes_enforce_login_user_and_feature_gates(api):
+ client, state, _ = api
+ requests = [
+ ("get", "/api/user/workflows/workflow/flow", {}),
+ ("get", "/api/user/workflows/workflow/runs/run/flow", {}),
+ ("post", "/api/user/workflows/flow-preview", {"json": {"definition": definition()}}),
+ ("get", "/api/group/workflows/workflow/flow?group_id=fictional-group", {}),
+ ("get", "/api/group/workflows/workflow/runs/run/flow?group_id=fictional-group", {}),
+ ("post", "/api/group/workflows/flow-preview?group_id=fictional-group", {"json": {"definition": definition()}}),
+ ]
+ for field, code in (("login", 401), ("user_role", 403)):
+ state[field] = False
+ for method, path, arguments in requests:
+ assert getattr(client, method)(path, **arguments).status_code == code
+ state[field] = True
+ state["settings"].update(allow_user_workflows=False, allow_group_workflows=False)
+ for method, path, arguments in requests:
+ assert getattr(client, method)(path, **arguments).status_code == 403
+
+
+def test_blueprint_cache_policy_covers_all_flow_and_execution_overlay_denials(api):
+ client, state, _ = api
+ rules = [rule for rule in client.application.url_map.iter_rules() if rule.endpoint.startswith("backend_workflows.")]
+ assert len(rules) == 26
+ for gate, status in (("login", 401), ("user_role", 403)):
+ state[gate] = False
+ for rule in rules:
+ path = rule.rule
+ for parameter, value in (
+ ("workflow_id", "workflow"), ("run_id", "run"), ("execution_id", "execution"),
+ ("int:attempt", "1"), ("int:iteration", "0"),
+ ):
+ path = path.replace(f"<{parameter}>", value)
+ response = client.open(path, method="GET" if "GET" in rule.methods else "POST")
+ assert response.status_code == status, rule.endpoint
+ assert response.cache_control.no_store and response.cache_control.private, rule.endpoint
+ state[gate] = True
+
+
+def test_inspection_cache_policy_does_not_change_other_workflow_endpoints(api):
+ client, _, _ = api
+ client.application.add_url_rule(
+ "/api/user/workflows/editor-options",
+ endpoint="backend_workflows.get_user_workflow_editor_options",
+ view_func=lambda: (jsonify({"options": []}), 200, {"Cache-Control": "private, max-age=60"}),
+ )
+ response = client.get("/api/user/workflows/editor-options")
+ assert response.status_code == 200 and response.headers["Cache-Control"] == "private, max-age=60"
diff --git a/functional_tests/test_workflow_execution_inspection_client.js b/functional_tests/test_workflow_execution_inspection_client.js
new file mode 100644
index 000000000..4936cbb1e
--- /dev/null
+++ b/functional_tests/test_workflow_execution_inspection_client.js
@@ -0,0 +1,260 @@
+// test_workflow_execution_inspection_client.js
+/*
+Functional tests for exact, read-only workflow execution inspection.
+Version: 0.261.121
+Implemented in: 0.261.121
+
+Uses the existing TypeScript compiler to execute the real client and guards.
+Only API transport is mocked; no browser, backend, or live service is needed.
+*/
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+const { beforeEach, test } = require('node:test');
+
+const root = path.resolve(__dirname, '..');
+const ts = require(path.join(root, 'application', 'v2_ui', 'node_modules', 'typescript'));
+const modules = new Map();
+const requests = [];
+let respond;
+
+class ApiError extends Error {
+ constructor(message, status, payload) {
+ super(message);
+ this.status = status;
+ this.payload = payload;
+ }
+}
+
+const api = {
+ get: async (url, signal) => {
+ requests.push({ url, signal });
+ return respond(url, signal);
+ },
+};
+
+function loadModule(name) {
+ if (name === './apiClient') return { api, ApiError };
+ if (modules.has(name)) return modules.get(name);
+ const filename = path.join(root, 'application', 'v2_ui', 'src', 'lib', `${name.replace('./', '')}.ts`);
+ const source = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
+ }).outputText;
+ const module = { exports: {} };
+ modules.set(name, module.exports);
+ vm.runInNewContext(source, {
+ module, exports: module.exports, require: loadModule, URL, URLSearchParams,
+ }, { filename });
+ return module.exports;
+}
+
+const {
+ fetchWorkflowExecutionForNode,
+ fetchWorkflowExecutionsPage,
+ fetchWorkflowRepeatStatePage,
+} = loadModule('./workflowExecutionHistory');
+const personal = { type: 'personal' };
+const group = { type: 'group', groupId: 'group/exact' };
+const mixedPath = [
+ { loop_id: 'outer_each', item_id: 'a'.repeat(64), index: 4999 },
+ { loop_id: 'repeat', iteration: 1000 },
+ { loop_id: 'inner_each', item_id: 'b'.repeat(64), index: 0 },
+];
+
+function execution(iterationPath = []) {
+ return {
+ execution_id: 'server-resolved-execution',
+ node_id: 'selected_node',
+ node_kind: 'task',
+ state: 'skipped',
+ attempt: 0,
+ iteration_path: structuredClone(iterationPath),
+ };
+}
+
+function responseFor(items = []) {
+ return { executions: items, next_cursor: null, total_count: items.length };
+}
+
+function requestedUrl() {
+ assert.equal(requests.length, 1, 'An exact lookup must make one request, never scan history.');
+ return new URL(requests[0].url, 'https://simplechat.test');
+}
+
+beforeEach(() => {
+ requests.length = 0;
+ respond = () => { throw new Error('Unexpected API request.'); };
+});
+
+test('root selection uses the exact server identity, one bounded GET, and its AbortSignal', async () => {
+ const saved = execution();
+ const controller = new AbortController();
+ respond = () => responseFor([saved]);
+ const result = await fetchWorkflowExecutionForNode(
+ personal, 'workflow/fixture', 'run:fixture', saved.node_id, [], controller.signal,
+ );
+ assert.equal(result, saved);
+ const url = requestedUrl();
+ assert.equal(url.pathname, '/api/user/workflows/workflow%2Ffixture/runs/run%3Afixture/executions');
+ assert.equal(url.searchParams.get('node_id'), saved.node_id);
+ assert.equal(url.searchParams.get('iteration_path'), '[]');
+ assert.equal(url.searchParams.get('limit'), '1');
+ assert.equal(url.searchParams.has('cursor'), false);
+ assert.equal(url.searchParams.has('group_id'), false);
+ assert.equal(requests[0].signal, controller.signal);
+});
+
+test('group selection retains every mixed frame and lifetime round 1001 independent of object key order', async () => {
+ const saved = execution(mixedPath);
+ saved.iteration_path = [
+ { index: 4999, item_id: 'a'.repeat(64), loop_id: 'outer_each' },
+ { iteration: 1000, loop_id: 'repeat' },
+ { item_id: 'b'.repeat(64), index: 0, loop_id: 'inner_each' },
+ ];
+ respond = () => responseFor([saved]);
+ assert.equal(await fetchWorkflowExecutionForNode(group, 'workflow', 'run', saved.node_id, mixedPath), saved);
+ const url = requestedUrl();
+ assert.equal(url.pathname, '/api/group/workflows/workflow/runs/run/executions');
+ assert.equal(url.searchParams.get('group_id'), group.groupId);
+ assert.deepEqual(JSON.parse(url.searchParams.get('iteration_path')), mixedPath);
+});
+
+test('only a valid zero-result envelope becomes unobserved, without a latest-execution fallback', async () => {
+ respond = () => responseFor();
+ assert.equal(await fetchWorkflowExecutionForNode(personal, 'workflow', 'run', 'selected_node', mixedPath), null);
+ assert.equal(requestedUrl().searchParams.get('limit'), '1');
+});
+
+test('malformed, unbounded, unknown, or contradictory success envelopes fail explicitly', async () => {
+ const saved = execution();
+ const invalidResponses = [
+ undefined, null, [], 'unknown', {}, { error: 'denied' },
+ { executions: null, next_cursor: null, total_count: 0 },
+ { executions: [], total_count: 0 },
+ { executions: [], next_cursor: null },
+ { executions: [], next_cursor: '', total_count: 0 },
+ { executions: [], next_cursor: 'next-page', total_count: 0 },
+ { executions: [], next_cursor: 0, total_count: 0 },
+ { executions: [], next_cursor: null, total_count: '0' },
+ { executions: [], next_cursor: null, total_count: -1 },
+ { executions: [], next_cursor: null, total_count: 1 },
+ { executions: [saved], next_cursor: null, total_count: 0 },
+ { ...responseFor(), unexpected: 'unknown-contract' },
+ responseFor([saved, { ...saved, execution_id: 'another-execution' }]),
+ responseFor([null]),
+ responseFor([{ ...saved, execution_id: '' }]),
+ responseFor([{ ...saved, node_kind: null }]),
+ responseFor([{ ...saved, state: null }]),
+ responseFor([{ ...saved, attempt: -1 }]),
+ responseFor([{ ...saved, attempt: 0.5 }]),
+ responseFor([{ ...saved, workflow_result: [] }]),
+ responseFor([{ ...saved, workflow_validation: [] }]),
+ responseFor([{ ...saved, iteration_path: undefined }]),
+ responseFor([{ ...saved, node_id: 'another_node' }]),
+ responseFor([{ ...saved, iteration_path: [{ loop_id: 'repeat', iteration: 0 }] }]),
+ ];
+ for (const value of invalidResponses) {
+ requests.length = 0;
+ respond = () => value;
+ await assert.rejects(fetchWorkflowExecutionForNode(personal, 'workflow', 'run', saved.node_id, []));
+ requestedUrl();
+ }
+});
+
+test('same node and innermost identity cannot hide a different outer scope or frame', async () => {
+ const differentPaths = [
+ mixedPath.slice(1),
+ [...mixedPath].reverse(),
+ [{ ...mixedPath[0], index: 4998 }, ...mixedPath.slice(1)],
+ [{ ...mixedPath[0], item_id: 'c'.repeat(64) }, ...mixedPath.slice(1)],
+ [mixedPath[0], { loop_id: 'different_repeat', iteration: 1000 }, mixedPath[2]],
+ [mixedPath[0], { loop_id: 'repeat', iteration: 999 }, mixedPath[2]],
+ [mixedPath[0], { ...mixedPath[1], item_id: 'c'.repeat(64), index: 1 }, mixedPath[2]],
+ [...mixedPath, { loop_id: 'too_deep', iteration: 0 }],
+ ];
+ for (const iterationPath of differentPaths) {
+ requests.length = 0;
+ respond = () => responseFor([execution(iterationPath)]);
+ await assert.rejects(fetchWorkflowExecutionForNode(group, 'workflow', 'run', 'selected_node', mixedPath));
+ requestedUrl();
+ }
+});
+
+test('invalid selectors are rejected before making a request', async () => {
+ const invalidSelectors = [
+ ['', []], [' ', []], ['n'.repeat(257), []],
+ ['selected_node', undefined], ['selected_node', null], ['selected_node', {}],
+ ['selected_node', [{ loop_id: 'repeat', iteration: 5000 }]],
+ ['selected_node', [{ loop_id: 'repeat', iteration: -1 }]],
+ ['selected_node', [{ loop_id: 'repeat', iteration: 1, extra: true }]],
+ ['selected_node', [{ loop_id: 'each', item_id: 'not-a-frozen-id', index: 0 }]],
+ ['selected_node', [{ loop_id: 'each', item_id: 'a'.repeat(64), index: 5000 }]],
+ ['selected_node', [{ loop_id: 'repeat', iteration: 0 }, { loop_id: 'repeat', iteration: 1 }]],
+ ['selected_node', [...mixedPath, { loop_id: 'fourth', iteration: 0 }]],
+ ];
+ for (const [nodeId, iterationPath] of invalidSelectors) {
+ await assert.rejects(fetchWorkflowExecutionForNode(personal, 'workflow', 'run', nodeId, iterationPath));
+ }
+ assert.equal(requests.length, 0);
+});
+
+test('caller mutation cannot retarget the identity of an in-flight lookup', async () => {
+ const selectedPath = structuredClone(mixedPath);
+ const saved = execution(selectedPath);
+ let finish;
+ respond = () => new Promise((resolve) => { finish = resolve; });
+ const pending = fetchWorkflowExecutionForNode(group, 'workflow', 'run', saved.node_id, selectedPath);
+ selectedPath[0].index = 0;
+ selectedPath[1].iteration = 1;
+ selectedPath.pop();
+ finish(responseFor([saved]));
+ assert.equal(await pending, saved);
+ assert.deepEqual(JSON.parse(requestedUrl().searchParams.get('iteration_path')), mixedPath);
+});
+
+test('source revocation, missing history, and abort remain errors, not empty-success or fallback', async () => {
+ for (const error of [
+ new ApiError('Source access revoked.', 403, {}),
+ new ApiError('Run unavailable.', 404, {}),
+ new DOMException('Request aborted.', 'AbortError'),
+ ]) {
+ requests.length = 0;
+ respond = () => { throw error; };
+ await assert.rejects(
+ fetchWorkflowExecutionForNode(personal, 'workflow', 'run', 'selected_node', []),
+ (actual) => actual === error,
+ );
+ requestedUrl();
+ }
+});
+
+test('ordinary history paging retains optional root paths, 50 defaults, and a 100-entry maximum', async () => {
+ const saved = execution();
+ delete saved.iteration_path;
+ respond = () => ({ executions: [saved], next_cursor: 'next', total_count: 3 });
+ const page = await fetchWorkflowExecutionsPage(personal, 'workflow', 'run', 'previous');
+ assert.equal(page.items[0], saved);
+ assert.equal(page.next_cursor, 'next');
+ assert.equal(requestedUrl().searchParams.get('limit'), '50');
+ assert.equal(requestedUrl().searchParams.get('cursor'), 'previous');
+ requests.length = 0;
+ await fetchWorkflowExecutionsPage(personal, 'workflow', 'run', null, 100);
+ assert.equal(requestedUrl().searchParams.get('limit'), '100');
+ requests.length = 0;
+ await assert.rejects(fetchWorkflowExecutionsPage(personal, 'workflow', 'run', null, 101));
+ assert.equal(requests.length, 0);
+});
+
+test('unavailable after-state for lifetime round 1001 stays unavailable rather than an eligible empty result', async () => {
+ respond = () => ({
+ repeat_execution_id: 'repeat-execution', iteration: 1000, phase: 'after',
+ available: false, states: [], next_cursor: null, total_count: 0,
+ });
+ const page = await fetchWorkflowRepeatStatePage(personal, 'workflow', 'run', 'repeat-execution', 1000, 'after', null);
+ assert.equal(page.metadata.stateAvailable, false);
+ assert.equal(page.items.length, 0);
+ assert.equal(page.next_cursor, null);
+ assert.equal(requestedUrl().searchParams.get('limit'), '50');
+});
diff --git a/functional_tests/test_workflow_flow_assets.py b/functional_tests/test_workflow_flow_assets.py
new file mode 100644
index 000000000..42ccf8800
--- /dev/null
+++ b/functional_tests/test_workflow_flow_assets.py
@@ -0,0 +1,89 @@
+# test_workflow_flow_assets.py
+"""
+Functional tests for locally bundled read-only workflow Flow assets.
+Version: 0.261.121
+Implemented in: 0.261.121
+
+Validate the approved dependency pin, retained dependency notices, static imports,
+and Vite's copied notice without downloading any browser or cloud resources.
+"""
+
+import json
+import re
+import unittest
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+UI = ROOT / "application" / "v2_ui"
+NOTICES = UI / "public" / "licenses" / "workflow-flow-notices.txt"
+PACKAGES = (
+ "node_modules/@xyflow/react",
+ "node_modules/@xyflow/system",
+ "node_modules/@xyflow/react/node_modules/zustand",
+ "node_modules/classcat",
+ "node_modules/use-sync-external-store",
+ "node_modules/d3-drag",
+ "node_modules/d3-dispatch",
+ "node_modules/d3-selection",
+ "node_modules/d3-interpolate",
+ "node_modules/d3-color",
+ "node_modules/d3-zoom",
+ "node_modules/d3-transition",
+ "node_modules/d3-ease",
+ "node_modules/d3-timer",
+ "node_modules/@types/d3-drag",
+ "node_modules/@types/d3-selection",
+ "node_modules/@types/d3-interpolate",
+ "node_modules/@types/d3-color",
+ "node_modules/@types/d3-transition",
+ "node_modules/@types/d3-zoom",
+)
+
+
+class WorkflowFlowAssetTests(unittest.TestCase):
+ def test_approved_renderer_is_exactly_pinned_and_locked(self):
+ manifest = json.loads((UI / "package.json").read_text(encoding="utf-8"))
+ lock = json.loads((UI / "package-lock.json").read_text(encoding="utf-8"))
+ self.assertEqual(manifest["dependencies"]["@xyflow/react"], "12.11.6")
+ self.assertEqual(lock["packages"]["node_modules/@xyflow/react"]["version"], "12.11.6")
+ self.assertTrue(lock["packages"]["node_modules/@xyflow/react"]["integrity"])
+
+ def test_locked_dependency_notices_are_retained(self):
+ lock = json.loads((UI / "package-lock.json").read_text(encoding="utf-8"))
+ notices = NOTICES.read_text(encoding="utf-8")
+ for package in PACKAGES:
+ with self.subTest(package=package):
+ name = package.split("node_modules/")[-1]
+ self.assertIn(f'{name} {lock["packages"][package]["version"]}', notices)
+ for owner in ("webkid GmbH", "Jorge Bucaran", "Paul Henschel", "Meta Platforms",
+ "Mike Bostock", "Robert Penner", "Microsoft Corporation"):
+ self.assertIn(owner, notices)
+ self.assertIn("Permission is hereby granted", notices)
+ self.assertIn("Permission to use, copy, modify", notices)
+ self.assertIn("Neither the name of the author", notices)
+
+ def test_flow_uses_static_local_bundle_imports(self):
+ directory = UI / "src" / "components" / "workflows"
+ canvas = (directory / "WorkflowFlowCanvas.tsx").read_text(encoding="utf-8")
+ self.assertIn("from '@xyflow/react'", canvas)
+ self.assertIn("import '@xyflow/react/dist/style.css'", canvas)
+ self.assertIn("import './WorkflowFlowView.css'", canvas)
+ for path in directory.glob("WorkflowFlow*"):
+ with self.subTest(path=path.name):
+ source = path.read_text(encoding="utf-8")
+ self.assertNotRegex(source, r"\bimport\s*\(")
+ self.assertNotRegex(source, r"\bnew\s+(?:Shared)?Worker\s*\(")
+ self.assertNotRegex(source, re.compile(r"url\(\s*['\"]?https?://", re.IGNORECASE))
+
+ def test_built_notice_matches_tracked_source_when_bundle_exists(self):
+ build = ROOT / "application" / "single_app" / "static" / "v2"
+ if not (build / "index.html").is_file():
+ self.skipTest("Build the local V2 bundle to verify copied public assets.")
+ copied = build / "licenses" / NOTICES.name
+ self.assertTrue(copied.is_file(), "Rebuild V2 to ship the required Flow notices.")
+ self.assertEqual(copied.read_bytes(), NOTICES.read_bytes())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/functional_tests/test_workflow_flow_inspection.py b/functional_tests/test_workflow_flow_inspection.py
new file mode 100644
index 000000000..905f7504b
--- /dev/null
+++ b/functional_tests/test_workflow_flow_inspection.py
@@ -0,0 +1,611 @@
+# test_workflow_flow_inspection.py
+"""
+Offline tests for compiler-derived Flow inspection and exact frozen executions.
+Version: 0.261.121
+Implemented in: 0.261.121
+
+Production compilers, identity, snapshot, journal and lineage readers use only
+fictional transactional stores. No model, source query or publication is invoked.
+"""
+
+import base64
+import copy
+import json
+import sys
+from pathlib import Path
+
+import pytest
+from azure.cosmos.exceptions import CosmosResourceNotFoundError
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "application" / "single_app"))
+
+# Production imports follow the isolated worktree import setup.
+import functions_workflow_inspection as inspection
+from functions_analysis_access import AnalysisResultUnavailable
+from functions_workflow_definitions import WorkflowDefinitionConflict, WorkflowDefinitionError, workflow_definition_revision
+from functions_workflow_execution import WorkflowSuspended
+from functions_workflow_execution_history import workflow_execution_history, workflow_execution_result_page
+from functions_workflow_flow import compile_workflow_flow
+from functions_workflow_identity import workflow_execution_id, workflow_node_identity
+from functions_workflow_inspection import (
+ WorkflowFlowDetailTooLarge, WorkflowFlowUnsupported, preview_workflow_flow,
+ workflow_flow_inspection, workflow_run_flow_inspection,
+)
+from functions_workflow_journal import journal_record_id
+from functions_workflow_result_store import WorkflowResultStore
+from functions_workflow_runtime_store import CONTROL_ID, WorkflowRuntimeConflict
+from test_workflow_for_each_execution import execute_loop, loop_definition, loop_runtime
+from test_workflow_repeat_execution import continue_repeat, execute_repeat, repeat_definition, repeat_runtime
+from test_workflow_structured_flow import binding, create_structured_runtime, definition, run_flow, task
+
+
+def forbidden(*args, **kwargs):
+ raise AssertionError("Inspection crossed a forbidden read, scan, or mutation boundary.")
+
+
+def details(workflow, node_id, section, **options):
+ return workflow_flow_inspection(
+ workflow, node_id=node_id, section=section, revision=workflow_definition_revision(workflow), **options,
+ )
+
+
+def test_topology_follows_regions_not_catalogue_and_is_lightweight():
+ workflow = definition()
+ workflow["name"] = "Fictional workflow"
+ workflow["model_binding_summary"] = {"endpoint": "https://private.invalid", "secret": "PRIVATE"}
+ workflow["last_run_response_preview"] = "PRIVATE_RESULT"
+ workflow["lease"] = {"token": "PRIVATE_TOKEN"}
+ workflow["tasks"][3]["document_action"] = {"type": "analyze", "document_ids": ["HEAVY_DOCUMENT"]}
+ workflow["tasks"][0]["publication"] = {
+ "artifact_format": "json", "workspace_scope": "personal", "completion_policy": "submitted",
+ }
+ before = copy.deepcopy(workflow)
+ compiled = compile_workflow_flow(workflow)
+ result = workflow_flow_inspection(workflow)
+ indexed = {node["id"]: node for node in result["nodes"]}
+ assert set(indexed) == set(compiled["nodes"]) | set(compiled["regions"])
+ assert result["projection_version"] == 1 and result["definition_version"] == 3
+ assert result["source"]["definition_revision"] == workflow_definition_revision(workflow)
+ assert [node["id"] for node in result["nodes"] if node["parent_id"] == "root"] == [
+ "classify-node", "choice", "joined", "report-node",
+ ]
+ assert [indexed[key]["order"] for key in ("classify-node", "choice", "joined", "report-node")] == [0, 1, 2, 3]
+ assert indexed["positive"]["parent_id"] == "choice"
+ assert indexed["yes-node"]["parent_id"] == indexed["yes-node"]["region_id"] == "positive"
+ assert indexed["joined"]["kind"] == "join" and "task_id" not in indexed["joined"]
+ assert indexed["classify-node"]["label"].startswith("Analyze:")
+ assert indexed["report-node"]["label"].startswith("Publish:")
+ serialized = json.dumps(result)
+ for private in ("PRIVATE", "HEAVY_DOCUMENT", "instructions", "schema", "result_ref", "model_binding_summary", "lease"):
+ assert private not in serialized
+ assert workflow == before
+ assert workflow_flow_inspection(workflow) == result
+
+
+def test_empty_branches_forward_routes_region_exits_and_skip_edges():
+ predicate = {"op": "eq", "left": {"literal": 1}, "right": {"literal": 2}}
+ workflow = {
+ "id": "workflow", "user_id": "owner", "definition_version": 3, "durable_execution": True,
+ "tasks": [task("first"), task("last")],
+ "flow": {"id": "root", "nodes": [
+ {"id": "first", "kind": "task", "task_id": "first", "run_when": predicate},
+ {"id": "forward", "kind": "route", "inputs": [], "condition": predicate, "target": {"node_id": "last"}},
+ {"id": "choice", "kind": "if", "inputs": [], "condition": predicate,
+ "then": {"id": "then", "nodes": [
+ {"id": "exit", "kind": "route", "inputs": [], "condition": predicate, "target": {"exit_region_id": "then"}},
+ ]}, "else": {"id": "else", "nodes": []}, "join": {"id": "join", "exports": []}},
+ {"id": "last", "kind": "task", "task_id": "last"},
+ ], "outputs": []},
+ }
+ result = workflow_flow_inspection(workflow)
+ connections = {(edge["source"], edge["target"], edge["kind"]): edge["label"] for edge in result["edges"]}
+ assert connections["choice", "then", "then"] == "True"
+ assert connections["choice", "else", "else"] == "False"
+ assert "Empty" in connections["else", "join", "join"]
+ assert "True" in connections["exit", "join", "exit"]
+ assert "False" in connections["exit", "join", "join"]
+ assert "True" in connections["forward", "last", "route"]
+ assert "False" in connections["forward", "choice", "sequence"]
+ assert "skip" in connections["first", "forward", "sequence"]
+ assert connections["join", "last", "sequence"] == "Next"
+ assert connections["last", "root", "complete"] == "Workflow complete"
+
+
+def mixed_definition():
+ workflow = repeat_definition(1000)
+ body = workflow["flow"]
+ body["id"], body["outputs"] = "outer-body", []
+ workflow["flow"] = {"id": "root", "nodes": [{
+ "id": "outer", "kind": "for_each", "max_items": 5000, "item_key": "source_identity", "inputs": [],
+ "iterable": {"kind": "documents", "documents": []}, "body": body,
+ }], "outputs": []}
+ workflow["tasks"].append(task("deep"))
+ body["nodes"][1]["body"]["nodes"].insert(0, {
+ "id": "inner", "kind": "for_each", "max_items": 5000, "item_key": "source_identity", "inputs": [],
+ "iterable": {"kind": "documents", "documents": []},
+ "body": {"id": "inner-body", "nodes": [{"id": "deep-node", "kind": "task", "task_id": "deep"}], "outputs": []},
+ })
+ return workflow
+
+
+def test_mixed_loops_have_one_template_and_post_body_finite_repeat():
+ workflow = mixed_definition()
+ result = workflow_flow_inspection(workflow)
+ nodes = {node["id"]: node for node in result["nodes"]}
+ assert nodes["deep-node"]["loop_ids"] == ["outer", "repeat", "inner"]
+ assert nodes["inner-body"]["parent_id"] == "inner"
+ assert nodes["repeat"]["max_iterations"] == 1000 and nodes["outer"]["max_items"] == 5000
+ assert len(result["nodes"]) == len(compile_workflow_flow(workflow)["node_loop_ids"])
+ assert len([node for node in result["nodes"] if node.get("task_id") == "body"]) == 1
+ assert any(edge["kind"] == "repeat" and "After body" in edge["label"] for edge in result["edges"])
+ assert any(edge["kind"] == "complete" and edge["label"] == "After body: Until true" for edge in result["edges"])
+ assert "state" not in nodes["repeat"] and "until" not in nodes["repeat"]
+ initial = details(workflow, "repeat", "inputs")["items"][0]["value"]
+ assert initial["name"] == "state" and initial["source"]["node_id"] == "source-node"
+ assert initial["expected_kind"] == "json" and initial["required"] is True
+ state = details(workflow, "repeat", "state")["items"][0]["value"]
+ assert set(state) == {"name", "initial", "next", "output_contract"}
+ assert details(workflow, "repeat", "condition")["items"][0]["value"]["op"] == "eq"
+ assert details(workflow, "repeat", "outputs")["items"][0]["value"] == {"name": "state", "output": "next"}
+
+
+def test_maximum_structural_ids_depth_and_loop_frames_do_not_expand_instances():
+ predicate = {"op": "eq", "left": {"literal": 1}, "right": {"literal": 2}}
+ body = {"id": "deep-region", "nodes": [
+ *[{"id": f"route-{index}", "kind": "route", "inputs": [], "condition": predicate,
+ "target": {"node_id": "task-node"}} for index in range(248)],
+ {"id": "task-node", "kind": "task", "task_id": "task"},
+ ], "outputs": []}
+ for index in range(3):
+ body = {"id": f"region-{index}", "nodes": [{
+ "id": f"loop-{index}", "kind": "for_each", "inputs": [],
+ "iterable": {"kind": "documents", "documents": []}, "max_items": 5000, "item_key": "source_identity",
+ "body": body,
+ }], "outputs": []}
+ workflow = {
+ "id": "workflow", "user_id": "owner", "definition_version": 3, "durable_execution": True,
+ "tasks": [task("task")], "flow": body,
+ }
+ result = workflow_flow_inspection(workflow)
+ assert len(result["nodes"]) == 256
+ selected = next(node for node in result["nodes"] if node["id"] == "task-node")
+ assert selected["loop_ids"] == ["loop-2", "loop-1", "loop-0"]
+ assert len(json.dumps(result, ensure_ascii=True).encode("ascii")) < 512 * 1024
+ inner = workflow["flow"]["nodes"][0]["body"]["nodes"][0]["body"]["nodes"][0]["body"]
+ inner["nodes"].insert(0, {
+ "id": "one-too-many", "kind": "route", "inputs": [], "condition": predicate, "target": {"node_id": "task-node"},
+ })
+ with pytest.raises(WorkflowDefinitionError, match="256"):
+ workflow_flow_inspection(workflow)
+
+
+def test_nested_configuration_allowlists_remove_private_runner_and_action_data():
+ workflow = definition()
+ workflow["tasks"][0]["runner"] = {
+ "type": "model", "model_endpoint_id": "fictional-model", "model_id": "fictional",
+ "selected_agent": {"id": "agent", "name": "safe", "api_key": "PRIVATE", "settings": {"secret": "PRIVATE"}},
+ "model_binding_summary": {"url": "https://provider.invalid", "api_key": "PRIVATE"},
+ }
+ workflow["tasks"][0]["document_action"] = {
+ "type": "analyze", "target_mode": "selected", "provider_url": "https://provider.invalid",
+ "api_key": "PRIVATE", "metadata": {"secret": "PRIVATE"},
+ }
+ page = details(workflow, "report-node", "configuration")
+ serialized = json.dumps(page)
+ assert "Use only declared inputs." in serialized and "fictional-model" in serialized
+ for value in ("PRIVATE", "provider.invalid", "model_binding_summary", "metadata", "api_key"):
+ assert value not in serialized
+ assert details(workflow, "joined", "outputs")["items"][0]["value"]["then"]["node_id"] == "yes-node"
+ workflow["tasks"][0]["runner"]["model_endpoint_id"] = "https://private.invalid"
+ with pytest.raises(WorkflowDefinitionError):
+ details(workflow, "report-node", "configuration")
+
+
+def test_full_authored_names_are_lazy_and_never_shortened_on_byte_overflow():
+ workflow = definition()
+ workflow["name"] = "Workflow " + "界" * 200 + " complete"
+ workflow["tasks"][0]["name"] = "Task " + "界" * 200 + " complete"
+ projection = workflow_flow_inspection(workflow)
+ assert projection["name"] == workflow["name"][:120]
+ task_node = next(node for node in projection["nodes"] if node["id"] == "report-node")
+ assert task_node["label"] == workflow["tasks"][0]["name"][:120]
+ for node_id, label, expected in (
+ ("root", "Workflow name", workflow["name"]),
+ ("report-node", "Task name", workflow["tasks"][0]["name"]),
+ ):
+ page = details(workflow, node_id, "configuration")
+ assert next(item["value"] for item in page["items"] if item["label"] == label) == expected
+ assert expected not in json.dumps(projection, ensure_ascii=False)
+ for name in ("x" * (inspection.FLOW_DETAIL_MAX_BYTES + 1), "界" * (inspection.FLOW_DETAIL_MAX_BYTES // 5)):
+ workflow["name"] = name
+ with pytest.raises(WorkflowFlowDetailTooLarge):
+ details(workflow, "root", "configuration")
+ workflow["name"] = "Workflow"
+ workflow["tasks"][0]["name"] = name
+ with pytest.raises(WorkflowFlowDetailTooLarge):
+ details(workflow, "report-node", "configuration")
+ workflow["tasks"][0]["name"] = {"private": "not a name"}
+ with pytest.raises(WorkflowDefinitionError):
+ details(workflow, "report-node", "configuration")
+
+
+def test_sections_bind_cursors_to_source_revision_node_and_section():
+ workflow = definition()
+ workflow["tasks"][0]["inputs"] = [binding("joined", f"input{index}", "answer") for index in range(70)]
+ first = details(workflow, "report-node", "inputs")
+ assert len(first["items"]) == 50 and first["total_count"] == 70 and first["next_cursor"]
+ second = details(workflow, "report-node", "inputs", cursor=first["next_cursor"])
+ assert len(second["items"]) == 20 and second["next_cursor"] is None
+ for node, section in (("report-node", "outputs"), ("choice", "inputs")):
+ with pytest.raises(ValueError):
+ details(workflow, node, section, cursor=first["next_cursor"])
+ other_scope = {**workflow, "user_id": "other"}
+ with pytest.raises(ValueError):
+ details(other_scope, "report-node", "inputs", cursor=first["next_cursor"])
+ value = json.loads(base64.urlsafe_b64decode(first["next_cursor"]))
+ for offset in (-1, True, 70, 71, 0.5):
+ cursor = base64.urlsafe_b64encode(json.dumps({**value, "offset": offset}).encode()).decode()
+ with pytest.raises(ValueError):
+ details(workflow, "report-node", "inputs", cursor=cursor)
+ for limit in (0, 101, True, "50"):
+ with pytest.raises(ValueError):
+ details(workflow, "report-node", "inputs", limit=limit)
+ with pytest.raises(WorkflowDefinitionConflict):
+ workflow_flow_inspection(workflow, node_id="report-node", section="inputs")
+ revision = workflow_definition_revision(workflow)
+ workflow["tasks"][0]["instructions"] = "Edited after loading topology."
+ with pytest.raises(WorkflowDefinitionConflict):
+ workflow_flow_inspection(workflow, node_id="report-node", section="inputs", revision=revision)
+
+
+def test_selection_and_detail_bytes_are_bounded_without_shortened_items(monkeypatch):
+ workflow = loop_definition()
+ loop = workflow["flow"]["nodes"][1]
+ loop["iterable"] = {"kind": "documents", "documents": [
+ {"scope_type": "personal", "document_id": f"document-{index}"} for index in range(120)
+ ]}
+ loop["inputs"] = []
+ first = details(workflow, "each", "selection", limit=50)
+ assert first["total_count"] == 121 and len(first["items"]) == 50
+ assert first["items"][1]["value"] == {"scope_type": "personal", "document_id": "document-0"}
+ monkeypatch.setattr(inspection, "FLOW_DETAIL_MAX_BYTES", 4000)
+ page = details(workflow, "each", "selection", limit=100)
+ assert 0 < len(page["items"]) < 100 and page["next_cursor"]
+ assert len(json.dumps(page, ensure_ascii=True).encode("ascii")) < 4000
+ workflow["tasks"][0]["instructions"] = "x" * 12000
+ with pytest.raises(WorkflowFlowDetailTooLarge):
+ details(workflow, "source-node", "configuration", limit=50)
+
+
+def test_preview_is_pure_scope_bound_and_does_not_replace_editor_cas(monkeypatch):
+ workflow = definition()
+ workflow.update(id="foreign-stored-id", user_id="foreign", group_id="foreign-group", definition_revision="original-cas")
+ original = copy.deepcopy(workflow)
+ monkeypatch.setattr(inspection, "workflow_runtime_store", forbidden)
+ monkeypatch.setattr(inspection, "authorize_workflow_flow_sources", forbidden)
+ monkeypatch.setattr("functions_workflow_runtime.queue_durable_workflow_run", forbidden)
+ monkeypatch.setattr("functions_workflow_loop_inputs.iter_workflow_loop_documents", forbidden)
+ monkeypatch.setattr("functions_workflow_iterations.freeze_workflow_loop", forbidden)
+ monkeypatch.setattr(WorkflowResultStore, "save", forbidden)
+ result = preview_workflow_flow(workflow, user_id="owner")
+ assert result["source"]["scope_type"] == "personal" and result["source"]["scope_id"] == "owner"
+ assert result["source"]["definition_revision"].startswith("DRAFT:")
+ assert workflow == original and workflow["definition_revision"] == "original-cas"
+ page = preview_workflow_flow(
+ workflow, user_id="owner", node_id="report-node", section="configuration",
+ revision=result["source"]["definition_revision"],
+ )
+ assert page["source"] == result["source"]
+ workflow["tasks"][0]["instructions"] = "Unsaved edit"
+ with pytest.raises(WorkflowDefinitionConflict):
+ preview_workflow_flow(
+ workflow, user_id="owner", node_id="report-node", section="configuration",
+ revision=result["source"]["definition_revision"],
+ )
+ workflow["flow"]["nodes"][0].pop("id")
+ with pytest.raises(WorkflowDefinitionError):
+ preview_workflow_flow(workflow, user_id="owner")
+
+
+def test_declared_query_sources_are_authorized_without_enumeration(monkeypatch):
+ workflow = loop_definition()
+ loop = workflow["flow"]["nodes"][1]
+ loop["inputs"] = []
+ loop["iterable"] = {
+ "kind": "workspace_query", "scopes": [{"scope_type": "group", "scope_id": "fictional-group"}],
+ "filters": {"tags": ["fictional"]}, "selection": {"mode": "all_matches"},
+ }
+ checked = []
+ allowed = {"value": True}
+
+ def authorize_scope(scope, *, actor_user_id):
+ checked.append((scope, actor_user_id))
+ return allowed["value"]
+
+ monkeypatch.setattr(inspection, "_default_authorize_scope", authorize_scope)
+ monkeypatch.setattr("functions_workflow_loop_inputs.iter_workflow_loop_documents", forbidden)
+ monkeypatch.setattr("functions_workflow_iterations.freeze_workflow_loop", forbidden)
+ inspection.authorize_workflow_flow_sources(workflow, reader_user_id="owner")
+ assert checked == [({"scope_type": "group", "scope_id": "fictional-group"}, "owner")]
+ allowed["value"] = False
+ with pytest.raises(AnalysisResultUnavailable):
+ inspection.authorize_workflow_flow_sources(workflow, reader_user_id="owner")
+
+
+def test_preview_keeps_known_list_metadata_without_relaxing_executable_validation(monkeypatch):
+ workflow = definition()
+ workflow["definition_revision"] = "original-editor-cas"
+ baseline = preview_workflow_flow(workflow, user_id="owner")
+ for field in ("metadata", "alerts", "alert_settings", "document_actions", "publication", "publication_options"):
+ workflow[field] = {"legacy": {"retained": False, "provider_url": "PRIVATE_METADATA", "count": 0}}
+ before = copy.deepcopy(workflow)
+ monkeypatch.setattr(inspection, "workflow_runtime_store", forbidden)
+ monkeypatch.setattr(inspection, "authorize_workflow_flow_sources", forbidden)
+ monkeypatch.setattr(WorkflowResultStore, "save", forbidden)
+ preview = preview_workflow_flow(workflow, user_id="owner")
+ assert preview == baseline and workflow == before
+ configuration = preview_workflow_flow(
+ workflow, user_id="owner", node_id="root", section="configuration",
+ revision=preview["source"]["definition_revision"],
+ )
+ assert "PRIVATE_METADATA" not in json.dumps(configuration)
+ assert workflow["definition_revision"] == "original-editor-cas"
+ workflow["parallel_execution"] = True
+ with pytest.raises(WorkflowDefinitionError, match="unsupported"):
+ preview_workflow_flow(workflow, user_id="owner")
+ workflow.pop("parallel_execution")
+ workflow["tasks"][0]["metadata"] = {"parallel_execution": True}
+ with pytest.raises(WorkflowDefinitionError, match="unsupported"):
+ preview_workflow_flow(workflow, user_id="owner")
+
+
+@pytest.mark.parametrize("version", [1, 2, 4, True, "3"])
+def test_unsupported_definitions_do_not_convert_or_mutate(version):
+ workflow = definition()
+ workflow["definition_version"] = version
+ original = copy.deepcopy(workflow)
+ with pytest.raises(WorkflowFlowUnsupported):
+ workflow_flow_inspection(workflow)
+ with pytest.raises(WorkflowFlowUnsupported):
+ preview_workflow_flow(workflow, user_id="owner")
+ assert workflow == original
+
+
+def test_frozen_run_uses_validated_snapshot_not_current_definition_or_limits(monkeypatch):
+ workflow, store, container, _, _ = repeat_runtime(monkeypatch, maximum=1000, policy=1000)
+ monkeypatch.setattr(inspection, "workflow_runtime_store", lambda *args: store)
+ monkeypatch.setattr(container, "query_items", forbidden)
+ before = copy.deepcopy(container.items)
+ live = copy.deepcopy(workflow)
+ live["flow"]["nodes"][1]["max_iterations"] = 1
+ live["tasks"][1]["instructions"] = "Changed live instructions."
+ frozen = workflow_run_flow_inspection(live, "run", reader_user_id="owner")
+ assert frozen["source"]["definition_revision"] == workflow_definition_revision(workflow)
+ assert frozen["source"]["definition_revision"] != workflow_definition_revision(live)
+ assert frozen["source"]["snapshot_sha256"] == store.read()["snapshot_ref"]["sha256"]
+ assert next(node for node in frozen["nodes"] if node["id"] == "repeat")["max_iterations"] == 1000
+ page = workflow_run_flow_inspection(
+ live, "run", reader_user_id="owner", node_id="body-node", section="configuration",
+ revision=frozen["source"]["definition_revision"],
+ )
+ assert "Changed live" not in json.dumps(page)
+ assert before == container.items
+ container.items = {key: value for key, value in container.items.items() if key[1] == CONTROL_ID}
+ with pytest.raises(CosmosResourceNotFoundError):
+ workflow_run_flow_inspection(live, "run", reader_user_id="owner")
+
+
+def test_run_schema_and_frozen_definition_version_fail_without_live_fallback(monkeypatch):
+ workflow, store, container, _ = create_structured_runtime(definition(), monkeypatch)
+ monkeypatch.setattr(inspection, "workflow_runtime_store", lambda *args: store)
+ control = container.items["run", CONTROL_ID]
+ control["schema_version"] = 1
+ with pytest.raises(WorkflowFlowUnsupported):
+ workflow_run_flow_inspection(workflow, "run", reader_user_id="owner")
+ control["schema_version"] = 2
+ snapshot = {**workflow, "definition_version": 4}
+ control["snapshot_ref"] = WorkflowResultStore(container).save(
+ workflow, "run", None, snapshot, node_id="root",
+ execution_id=workflow_execution_id(workflow, "run", "root"), attempt=1, iteration_path=[],
+ )
+ control["definition_revision"] = workflow_definition_revision(snapshot)
+ with pytest.raises(WorkflowFlowUnsupported):
+ workflow_run_flow_inspection(workflow, "run", reader_user_id="owner")
+
+
+def test_corrupt_snapshot_digest_and_reference_fail_without_live_fallback(monkeypatch):
+ workflow, store, container, _ = create_structured_runtime(definition(), monkeypatch)
+ monkeypatch.setattr(inspection, "workflow_runtime_store", lambda *args: store)
+ control = container.items["run", CONTROL_ID]
+ control["definition_revision"] = "f" * 64
+ with pytest.raises(WorkflowRuntimeConflict):
+ workflow_run_flow_inspection(workflow, "run", reader_user_id="owner")
+ control["definition_revision"] = workflow_definition_revision(workflow)
+ control["snapshot_ref"] = {}
+ with pytest.raises(ValueError):
+ workflow_run_flow_inspection(workflow, "run", reader_user_id="owner")
+
+
+def test_exact_execution_is_frozen_point_read_and_keeps_attempt_identity(monkeypatch):
+ workflow, store, container, _ = create_structured_runtime(definition(), monkeypatch)
+ run_flow(workflow, store)
+ monkeypatch.setattr("functions_workflow_execution_history.workflow_runtime_store", lambda *args: store)
+ monkeypatch.setattr(container, "query_items", forbidden)
+ live = copy.deepcopy(workflow)
+ live["tasks"][0]["instructions"] = "A different live revision."
+ page = workflow_execution_history(live, "run", reader_user_id="owner", node_id="report-node", iteration_path=[])
+ execution = page["executions"][0]
+ assert page["total_count"] == 1 and page["next_cursor"] is None
+ assert execution["execution_id"] == workflow_execution_id(workflow, "run", "report-node", [])
+ assert execution["execution_id"] != workflow_execution_id(live, "run", "report-node", [])
+ assert execution["workflow_result"]["producer"]["attempt"] == execution["attempt"] == 1
+ later = workflow_node_identity(workflow, "run", "report-node", execution["execution_id"], 2, task_id="report")
+ assert later["execution_id"] == execution["execution_id"] and later["attempt"] == 2
+ with pytest.raises(LookupError):
+ workflow_execution_result_page(live, "run", execution["execution_id"], 2, reader_user_id="owner")
+ for options in (
+ {"node_id": "report-node"}, {"iteration_path": []}, {"node_id": "positive", "iteration_path": []},
+ {"node_id": "missing", "iteration_path": []}, {"node_id": "report-node", "iteration_path": [], "cursor": ""},
+ {"node_id": "report-node", "iteration_path": [], "kind": "attempt"},
+ {"node_id": "report-node", "iteration_path": [{"loop_id": "foreign", "iteration": 0}]},
+ ):
+ with pytest.raises(ValueError):
+ workflow_execution_history(workflow, "run", reader_user_id="owner", **options)
+
+
+@pytest.mark.parametrize("node_id", ["root", "report-node"])
+def test_unrecorded_valid_execution_is_only_zero_record_metadata(monkeypatch, node_id):
+ workflow, store, container, _ = create_structured_runtime(definition(), monkeypatch)
+ monkeypatch.setattr("functions_workflow_execution_history.workflow_runtime_store", lambda *args: store)
+ monkeypatch.setattr(container, "query_items", forbidden)
+ before = copy.deepcopy(container.items)
+ live = copy.deepcopy(workflow)
+ live["tasks"][0]["instructions"] = "A different live revision."
+ response = workflow_execution_history(live, "run", reader_user_id="owner", node_id=node_id, iteration_path=[])
+ assert response == {"executions": [], "next_cursor": None, "total_count": 0}
+ assert container.items == before
+ with pytest.raises(LookupError):
+ workflow_execution_result_page(
+ live, "run", workflow_execution_id(workflow, "run", node_id), 1, reader_user_id="owner",
+ )
+
+
+def test_exact_for_each_lookup_validates_frozen_membership_without_history_scan(monkeypatch):
+ workflow, store, container, _ = loop_runtime(monkeypatch)
+ _, calls = execute_loop(workflow, store, [{"value": 1}, {"value": 2}])
+ path = next(call[1] for call in calls if call[0] == "body")
+ monkeypatch.setattr(container, "query_items", forbidden)
+ result = workflow_execution_history(workflow, "run", reader_user_id="owner", node_id="body-node", iteration_path=path)
+ assert result["executions"][0]["iteration_path"] == path
+ with pytest.raises(ValueError):
+ workflow_execution_history(workflow, "run", reader_user_id="owner", node_id="body-node", iteration_path=[])
+ wrong = [{**path[0], "item_id": "f" * 64}]
+ with pytest.raises(AnalysisResultUnavailable):
+ workflow_execution_history(workflow, "run", reader_user_id="owner", node_id="body-node", iteration_path=wrong)
+ row = store.journal_read("execution", result["executions"][0]["execution_id"])
+ container.items.pop(("run", row["id"]))
+ assert workflow_execution_history(
+ workflow, "run", reader_user_id="owner", node_id="body-node", iteration_path=path,
+ ) == {"executions": [], "next_cursor": None, "total_count": 0}
+ container.items["run", row["id"]] = row
+ container.items["run", row["id"]]["payload"]["iteration_inputs"] = []
+ with pytest.raises(AnalysisResultUnavailable):
+ workflow_execution_history(workflow, "run", reader_user_id="owner", node_id="body-node", iteration_path=path)
+ container.items["run", row["id"]]["scope_id"] = "foreign"
+ with pytest.raises(WorkflowRuntimeConflict):
+ workflow_execution_history(workflow, "run", reader_user_id="owner", node_id="body-node", iteration_path=path)
+
+
+def test_unrecorded_repeat_node_still_requires_a_sealed_admission(monkeypatch):
+ workflow, store, container, _, _ = repeat_runtime(monkeypatch)
+ execute_repeat(workflow, store, target=1)
+ path = [{"loop_id": "repeat", "iteration": 0}]
+ execution_id = workflow_execution_id(workflow, "run", "body-node", path)
+ container.items.pop(("run", journal_record_id("execution", execution_id)))
+ monkeypatch.setattr(container, "query_items", forbidden)
+ live = copy.deepcopy(workflow)
+ live["flow"]["nodes"][1]["max_iterations"] = 1
+ assert workflow_execution_history(
+ live, "run", reader_user_id="owner", node_id="body-node", iteration_path=path,
+ ) == {"executions": [], "next_cursor": None, "total_count": 0}
+ with pytest.raises(AnalysisResultUnavailable):
+ workflow_execution_history(
+ live, "run", reader_user_id="owner", node_id="body-node",
+ iteration_path=[{"loop_id": "repeat", "iteration": 1}],
+ )
+ loop_id = workflow_execution_id(workflow, "run", "repeat", [])
+ container.items.pop(("run", journal_record_id("admission", ["repeat-iteration", loop_id, 0])))
+ with pytest.raises(AnalysisResultUnavailable):
+ workflow_execution_history(live, "run", reader_user_id="owner", node_id="body-node", iteration_path=path)
+
+
+def test_unrecorded_mixed_path_requires_both_repeat_admission_and_frozen_item(monkeypatch):
+ draft = repeat_definition(1)
+ contract = {"kind": "records", "schema": {"type": "array", "items": {"type": "object"}}}
+ draft["tasks"].extend([
+ task("rows-source", contract=contract),
+ task("leaf", inputs=[{
+ "name": "item", "source": {"kind": "loop_item", "loop_id": "each", "scope": "current"},
+ }], contract=contract),
+ ])
+ draft["flow"]["nodes"].insert(1, {"id": "rows-source-node", "kind": "task", "task_id": "rows-source"})
+ draft["flow"]["nodes"][2]["body"]["nodes"].append({
+ "id": "each", "kind": "for_each", "max_items": 2, "item_key": "source_identity",
+ "inputs": [binding("rows-source-node", "rows", "records")],
+ "iterable": {"kind": "input", "name": "rows"},
+ "body": {"id": "each-body", "nodes": [{"id": "leaf-node", "kind": "task", "task_id": "leaf"}], "outputs": []},
+ })
+ workflow, store, container, _, _ = repeat_runtime(monkeypatch, definition=draft)
+
+ def produce(current, resolved, execution):
+ if current["id"] == "rows-source":
+ kind, value = "records", [{"row": 1}]
+ elif current["id"] == "leaf":
+ kind, value = "records", [resolved["values"]["item"]["value"]]
+ else:
+ kind = "json"
+ value = {"count": int(current["id"] != "source"), "ready": current["id"] != "source"}
+ return {"reply": "", "authoritative_result": {"kind": kind, "value": value}}
+
+ _, calls = execute_repeat(workflow, store, result_for_task=produce)
+ _, path, execution_id = next(call for call in calls if call[0] == "leaf")
+ assert [frame["loop_id"] for frame in path] == ["repeat", "each"]
+ container.items.pop(("run", journal_record_id("execution", execution_id)))
+ monkeypatch.setattr(container, "query_items", forbidden)
+ assert workflow_execution_history(
+ workflow, "run", reader_user_id="owner", node_id="leaf-node", iteration_path=path,
+ ) == {"executions": [], "next_cursor": None, "total_count": 0}
+ for wrong in (
+ [path[0], {**path[1], "item_id": "f" * 64}],
+ [{**path[0], "iteration": 1}, path[1]],
+ ):
+ with pytest.raises(AnalysisResultUnavailable):
+ workflow_execution_history(workflow, "run", reader_user_id="owner", node_id="leaf-node", iteration_path=wrong)
+
+
+def test_exact_repeat_round_1001_is_lifetime_not_current_batch_or_live_revision(monkeypatch):
+ workflow, store, container, _, _ = repeat_runtime(monkeypatch, maximum=1000, policy=1000)
+ with pytest.raises(WorkflowSuspended):
+ execute_repeat(workflow, store, target=1001)
+ continue_repeat(store)
+ execute_repeat(workflow, store, target=1001)
+ monkeypatch.setattr(container, "query_items", forbidden)
+ live = copy.deepcopy(workflow)
+ live["flow"]["nodes"][1]["max_iterations"] = 1
+ live["tasks"][1]["instructions"] = "Changed after admission."
+ path = [{"loop_id": "repeat", "iteration": 1000}]
+ page = workflow_execution_history(live, "run", reader_user_id="owner", node_id="body-node", iteration_path=path)
+ assert page["executions"][0]["iteration_path"] == path
+ assert page["executions"][0]["execution_id"] == workflow_execution_id(workflow, "run", "body-node", path)
+ assert page["executions"][0]["attempt"] == 1
+ container.items.pop(("run", journal_record_id("execution", page["executions"][0]["execution_id"])))
+ assert workflow_execution_history(
+ live, "run", reader_user_id="owner", node_id="body-node", iteration_path=path,
+ ) == {"executions": [], "next_cursor": None, "total_count": 0}
+
+
+def test_exact_payload_reuses_source_authorization_and_private_projection(monkeypatch):
+ workflow, store, container, _ = create_structured_runtime(definition(), monkeypatch)
+ run_flow(workflow, store)
+ monkeypatch.setattr("functions_workflow_execution_history.workflow_runtime_store", lambda *args: store)
+ identifier = workflow_execution_id(workflow, "run", "classify-node")
+ row = store.journal_read("execution", identifier)
+ payload = container.items["run", row["id"]]["payload"]
+ payload["reference_sources"] = [{"document_id": "source", "scope": "personal", "scope_id": "owner"}]
+ payload["lease"] = {"token": "PRIVATE"}
+ access = {"allowed": True}
+
+ def authorize(user, sources):
+ assert user == "owner" and sources[0]["document_id"] == "source"
+ if not access["allowed"]:
+ raise AnalysisResultUnavailable()
+
+ monkeypatch.setattr("functions_workflow_execution_history.authorize_analysis_sources", authorize)
+ result = workflow_execution_history(workflow, "run", reader_user_id="owner", node_id="classify-node", iteration_path=[])
+ assert "PRIVATE" not in json.dumps(result) and "reference_sources" not in json.dumps(result)
+ access["allowed"] = False
+ with pytest.raises(AnalysisResultUnavailable):
+ workflow_execution_history(workflow, "run", reader_user_id="owner", node_id="classify-node", iteration_path=[])
+ payload["node_id"] = "foreign-node"
+ with pytest.raises(ValueError):
+ workflow_execution_history(workflow, "run", reader_user_id="owner", node_id="classify-node", iteration_path=[])
diff --git a/functional_tests/test_workflow_flow_layout.py b/functional_tests/test_workflow_flow_layout.py
new file mode 100644
index 000000000..5c1bdac5d
--- /dev/null
+++ b/functional_tests/test_workflow_flow_layout.py
@@ -0,0 +1,697 @@
+# test_workflow_flow_layout.py
+"""
+Offline regressions for the production Flow projection, TypeScript guard and layout.
+Version: 0.261.121
+Implemented in: 0.261.121
+
+Imports the real compiler and projection functions, and executes the actual
+TypeScript through test_support/tsResolve.mjs. A fresh-process audit rejects
+application configuration imports and network access during saved/draft detail
+reads. No bundle, browser, workflow invocation or storage write is needed.
+"""
+
+import copy
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[1]
+APP_ROOT = ROOT / "application" / "single_app"
+sys.path.insert(0, str(APP_ROOT))
+
+# Production pure modules require the application import path, not config.py.
+import functions_workflow_inspection as inspection
+from functions_workflow_definitions import workflow_definition_revision
+from functions_workflow_flow import compile_workflow_flow
+
+
+def maximum_structured_definition():
+ """256 canonical IDs, region depth four, and three collapsed loop templates."""
+ binding = {
+ "name": "decision",
+ "source": {"kind": "node_output", "node_id": "seed", "output": "json", "scope": "current"},
+ "required": True, "expected_kind": "json", "allow_partial": False,
+ }
+ nodes = [{"id": "seed", "kind": "task", "task_id": "seed-task"}]
+ for index in range(62):
+ nodes.append({
+ "id": f"if-{index}", "kind": "if", "inputs": [copy.deepcopy(binding)],
+ "condition": {"op": "eq", "left": {"input": "decision", "path": "/ready"}, "right": {"literal": True}},
+ "then": {"id": f"then-{index}", "nodes": []},
+ "else": {"id": f"else-{index}", "nodes": []},
+ "join": {"id": f"join-{index}", "exports": []},
+ })
+ current = nodes
+ for index in range(3):
+ loop = {
+ "id": f"each-{index}", "kind": "for_each", "max_items": 5000,
+ "item_key": "source_identity", "inputs": [],
+ "iterable": {"kind": "documents", "documents": [{
+ "document_id": f"fictional-source-{index}", "scope_type": "personal",
+ }]},
+ "body": {"id": f"body-{index}", "nodes": [], "outputs": []},
+ }
+ current.append(loop)
+ current = loop["body"]["nodes"]
+ return {
+ "id": "maximum-flow", "user_id": "fictional-flow-reader",
+ "name": "Maximum bounded Flow", "definition_version": 3, "durable_execution": True,
+ "tasks": [{
+ "id": "seed-task", "type": "instructions", "name": "Seed decision",
+ "instructions": "Return a typed ready Boolean; this fixture never runs.",
+ "inputs": [], "reference_ids": [], "runner": {"type": "inherit"},
+ "output_contract": {
+ "kind": "json", "allow_partial": False, "require_complete_coverage": False,
+ "schema": {"type": "object", "properties": {"ready": {"type": "boolean"}}, "required": ["ready"]},
+ },
+ }],
+ "flow": {"id": "root", "nodes": nodes, "outputs": []},
+ "limits": {"max_executions": 5000, "deadline_seconds": 86400},
+ }
+
+
+def binding_structured_definition():
+ """Compiler-authored branches, loop state, item inputs and collection exports."""
+ definition = maximum_structured_definition()
+ template = definition["tasks"][0]
+ tasks = {
+ identifier: {
+ **copy.deepcopy(template), "id": f"{identifier}-task", "name": f"Inspect {identifier}",
+ }
+ for identifier in ("seed", "accepted", "reviewed", "update", "process-document")
+ }
+
+ def binding(name, node_id, output="json", kind="json"):
+ return {
+ "name": name,
+ "source": {"kind": "node_output", "node_id": node_id, "output": output, "scope": "current"},
+ "required": True, "expected_kind": kind, "allow_partial": False,
+ }
+
+ tasks["update"]["inputs"] = [{
+ **binding("current_review", "review-loop"),
+ "source": {"kind": "repeat_state", "loop_id": "review-loop", "state_name": "review", "scope": "current"},
+ }, binding("seed_decision", "seed")]
+ records = {
+ "kind": "records", "allow_partial": False, "require_complete_coverage": False,
+ "schema": {"type": "array", "items": {
+ "type": "object", "properties": {"finding": {"type": "string"}}, "required": ["finding"],
+ }},
+ }
+ tasks["process-document"]["output_contract"] = records
+ tasks["process-document"]["inputs"] = [{
+ **binding("document", "each"),
+ "source": {"kind": "loop_item", "loop_id": "each", "scope": "current"},
+ }, binding("latest_review", "review-loop", "review")]
+ definition.update(
+ id="binding-flow", name="Typed inspection boundaries", tasks=list(reversed(list(tasks.values()))),
+ reference_inputs=[{
+ "id": f"ref-{index}", "name": f"reference_{index}", "document_id": f"reference-document-{index}",
+ "scope_type": "personal",
+ } for index in range(2)],
+ flow={
+ "id": "root",
+ "nodes": [
+ {"id": "seed", "kind": "task", "task_id": "seed-task"},
+ {
+ "id": "choose", "kind": "if", "inputs": [binding("decision", "seed")],
+ "condition": {
+ "op": "eq", "left": {"input": "decision", "path": "/ready"}, "right": {"literal": True},
+ },
+ "then": {"id": "then-path", "nodes": [{"id": "accepted", "kind": "task", "task_id": "accepted-task"}]},
+ "else": {"id": "else-path", "nodes": [{"id": "reviewed", "kind": "task", "task_id": "reviewed-task"}]},
+ "join": {
+ "id": "decision-join",
+ "exports": [{
+ "name": "review", "expected_kind": "json", "required": True,
+ "then": {"node_id": "accepted", "output": "json"},
+ "else": {"node_id": "reviewed", "output": "json"},
+ }],
+ },
+ },
+ {
+ "id": "review-loop", "kind": "repeat_until", "max_iterations": 1,
+ "state": [{
+ "name": "review", "initial": binding("review", "decision-join", "review")["source"],
+ "next": "next_review", "output_contract": copy.deepcopy(template["output_contract"]),
+ }],
+ "body": {
+ "id": "review-body", "nodes": [{"id": "update", "kind": "task", "task_id": "update-task"}],
+ "outputs": [binding("next_review", "update")],
+ },
+ "until": {
+ "op": "eq", "left": {"input": "review", "path": "/ready"}, "right": {"literal": True},
+ },
+ "exports": [{"name": "review", "output": "next_review"}],
+ },
+ {
+ "id": "each", "kind": "for_each", "max_items": 5000, "item_key": "source_identity", "inputs": [],
+ "iterable": {"kind": "documents", "documents": [{
+ "document_id": "fictional-source", "scope_type": "personal",
+ }]},
+ "body": {
+ "id": "each-body",
+ "nodes": [{"id": "process-document", "kind": "task", "task_id": "process-document-task"}],
+ "outputs": [binding("rows", "process-document", "records", "records")],
+ },
+ },
+ {
+ "id": "all-records", "kind": "collect", "source": {"loop_id": "each", "output": "rows"},
+ "output_contract": copy.deepcopy(records),
+ },
+ ],
+ "outputs": [binding("results", "all-records", "records", "records")],
+ },
+ )
+ tasks["seed"]["reference_ids"] = ["ref-1"]
+ return definition
+
+
+NODE_CHECKS = r"""
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+const root = process.argv[1];
+const input = JSON.parse(readFileSync(0, 'utf8'));
+await import(pathToFileURL(path.join(root, 'functional_tests', 'test_support', 'tsResolve.mjs')));
+const moduleUrl = (name) => pathToFileURL(path.join(root, 'application', 'v2_ui', 'src', 'lib', `${name}.ts`));
+const inspection = await import(moduleUrl('workflowInspection'));
+const layout = await import(moduleUrl('workflowFlowLayout'));
+const personal = { type: 'personal' };
+const target = { kind: 'saved', workflowId: input.projection.source.workflow_id };
+const projection = inspection.parseWorkflowFlowProjection(input.projection, personal, target);
+
+function frozen(value) {
+ if (value && typeof value === 'object') {
+ Object.values(value).forEach(frozen);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+if (input.check === 'layout') {
+ const before = JSON.stringify(projection);
+ frozen(projection);
+ const started = performance.now();
+ const boxes = layout.layoutWorkflowFlow(projection, new Set());
+ assert.equal(boxes.length, 256);
+ assert.ok(performance.now() - started < 2000, '256-node pure layout exceeded two seconds.');
+ assert.deepEqual(layout.layoutWorkflowFlow(projection, new Set()), boxes);
+ const positioned = new Map();
+ for (const box of boxes) {
+ for (const value of [box.position.x, box.position.y, box.width, box.height]) assert.ok(Number.isFinite(value));
+ assert.ok(box.width > 0 && box.height > 0);
+ if (box.parentId) {
+ const parent = positioned.get(box.parentId);
+ assert.ok(parent, `Parent must precede ${box.id}.`);
+ assert.ok(box.position.x >= 0 && box.position.y >= 0);
+ assert.ok(box.position.x + box.width <= parent.width);
+ assert.ok(box.position.y + box.height <= parent.height);
+ }
+ for (const sibling of positioned.values()) {
+ if (!box.parentId || sibling.parentId !== box.parentId) continue;
+ assert.ok(
+ box.position.x + box.width <= sibling.position.x ||
+ sibling.position.x + sibling.width <= box.position.x ||
+ box.position.y + box.height <= sibling.position.y ||
+ sibling.position.y + sibling.height <= box.position.y,
+ `${box.id} overlaps sibling ${sibling.id}.`,
+ );
+ }
+ positioned.set(box.id, box);
+ }
+ for (let index = 0; index < 62; index += 1) {
+ const then = positioned.get(`then-${index}`);
+ const otherwise = positioned.get(`else-${index}`);
+ const branch = positioned.get(`if-${index}`);
+ const join = positioned.get(`join-${index}`);
+ assert.equal(then.position.y, otherwise.position.y);
+ assert.ok(then.position.x + then.width < otherwise.position.x);
+ assert.ok(join.position.y >= branch.position.y + branch.height);
+ }
+ const renamed = structuredClone(projection);
+ renamed.nodes.forEach((node) => { node.label = ' '; });
+ assert.deepEqual(layout.layoutWorkflowFlow(renamed, new Set()), boxes);
+ assert.equal(JSON.stringify(projection), before, 'Layout mutated executable projection data.');
+} else if (input.check === 'collapse') {
+ const collapsed = new Set(['each-0', 'each-1', 'each-2']);
+ const boxes = layout.layoutWorkflowFlow(projection, collapsed);
+ assert.equal(boxes.length, 251, 'Loop instances must not become graph nodes.');
+ assert.ok(!boxes.some((box) => box.id === 'body-0' || box.id === 'each-1'));
+ const visibleIds = new Set(boxes.map((box) => box.id));
+ const edges = layout.visibleWorkflowEdges(projection, collapsed);
+ const keys = new Set();
+ for (const edge of edges) {
+ assert.ok(visibleIds.has(edge.source) && visibleIds.has(edge.target));
+ assert.notEqual(edge.source, edge.target);
+ const key = JSON.stringify([edge.source, edge.target, edge.kind, edge.label]);
+ assert.ok(!keys.has(key), 'Collapsed relationships must not be duplicated.');
+ keys.add(key);
+ }
+ const byId = new Map(projection.nodes.map((node) => [node.id, node]));
+ assert.equal(layout.visibleWorkflowNode('body-2', byId, collapsed), 'each-0');
+ assert.throws(() => layout.visibleWorkflowNode('missing', byId, collapsed), /unavailable/);
+ assert.ok(edges.some((edge) => edge.label.startsWith('Empty region:')));
+ assert.equal(layout.layoutWorkflowFlow(projection, new Set()).length, 256);
+} else if (input.check === 'guards') {
+ assert.deepEqual(
+ projection.nodes.filter((node) => inspection.inspectionNodeHasExecution(node, projection.root_region_id))
+ .map((node) => node.id).sort(),
+ [...input.execution_node_ids].sort(),
+ 'Only compiler nodes and the engine root can have execution records; nested regions cannot.',
+ );
+ const reject = (change) => {
+ const value = structuredClone(projection);
+ change(value);
+ assert.throws(() => inspection.parseWorkflowFlowProjection(value, personal, target), /unsupported|mismatched/);
+ };
+ reject((value) => { value.settings = { private_key: 'must-not-cross' }; });
+ reject((value) => { value.nodes[1].instructions = 'No eager task instructions'; });
+ reject((value) => { value.nodes.push(structuredClone(value.nodes[1])); });
+ reject((value) => { value.nodes[1].id = value.nodes[0].id; });
+ reject((value) => { value.nodes[1].parent_id = value.nodes[1].id; });
+ reject((value) => { value.nodes[1].parent_id = null; });
+ reject((value) => { value.nodes[1].loop_ids = ['each-0']; });
+ reject((value) => { value.edges[0].target = 'missing'; });
+ reject((value) => { value.edges.push(structuredClone(value.edges[0])); });
+ reject((value) => { value.source.workflow_id = 'another-workflow'; });
+ reject((value) => { value.source.definition_revision = 'not-a-revision'; });
+ reject((value) => { value.limits.max_executions = 5001; });
+ reject((value) => { value.definition_version = 2; });
+ reject((value) => { value.nodes.find((node) => node.id === 'each-0').max_items = 5001; });
+ const group = { type: 'group', groupId: 'group-alpha' };
+ const grouped = structuredClone(projection);
+ grouped.source.scope_type = 'group';
+ grouped.source.scope_id = 'group-alpha';
+ inspection.parseWorkflowFlowProjection(grouped, group, target);
+ assert.throws(() => inspection.parseWorkflowFlowProjection(grouped, { ...group, groupId: 'group-beta' }, target));
+ const run = structuredClone(projection);
+ run.source.kind = 'run';
+ run.source.run_id = 'frozen-run';
+ run.source.snapshot_sha256 = 'a'.repeat(64);
+ inspection.parseWorkflowFlowProjection(run, personal, { ...target, kind: 'run', runId: 'frozen-run' });
+ assert.throws(() => inspection.parseWorkflowFlowProjection(run, personal, { ...target, kind: 'run', runId: 'other-run' }));
+ assert.throws(() => inspection.parseWorkflowFlowProjection(run, personal, target));
+ const draft = { kind: 'draft', definition: input.definition };
+ inspection.parseWorkflowFlowProjection(input.preview, personal, draft);
+ assert.match(input.preview.source.definition_revision, /^DRAFT:[a-f0-9]{64}$/);
+ assert.throws(() => inspection.parseWorkflowFlowProjection(input.preview, personal, target));
+ const unmarkedDraft = structuredClone(input.preview);
+ unmarkedDraft.source.definition_revision = unmarkedDraft.source.definition_revision.slice(6);
+ assert.throws(() => inspection.parseWorkflowFlowProjection(unmarkedDraft, personal, draft));
+ reject((value) => { value.source.definition_revision = input.preview.source.definition_revision; });
+ const prefixedRun = structuredClone(run);
+ prefixedRun.source.definition_revision = input.preview.source.definition_revision;
+ assert.throws(() => inspection.parseWorkflowFlowProjection(
+ prefixedRun, personal, { ...target, kind: 'run', runId: 'frozen-run' },
+ ));
+} else if (input.check === 'details') {
+ const source = projection.source;
+ const details = {
+ projection_version: 1, source, node_id: 'seed', section: 'configuration',
+ items: [{ label: 'Exact JSON', value: { zero: 0, false: false, empty: [], nil: null } }],
+ total_count: 1, next_cursor: null,
+ };
+ assert.deepEqual(inspection.parseWorkflowInspectionDetails(details, source, 'seed', 'configuration').items, details.items);
+ assert.equal(inspection.parseWorkflowInspectionDetails(
+ { ...details, total_count: Number.MAX_SAFE_INTEGER, next_cursor: 'more-details' },
+ source, 'seed', 'configuration',
+ ).total_count, Number.MAX_SAFE_INTEGER);
+ const reject = (change) => {
+ const value = structuredClone(details);
+ change(value);
+ assert.throws(() => inspection.parseWorkflowInspectionDetails(value, source, 'seed', 'configuration'), /unsupported|mismatched/);
+ };
+ reject((value) => { value.source.definition_revision = 'b'.repeat(64); });
+ reject((value) => { value.source.scope_id = 'another-reader'; });
+ reject((value) => { value.node_id = 'other'; });
+ reject((value) => { value.section = 'outputs'; });
+ reject((value) => { value.private_snapshot = {}; });
+ reject((value) => { value.items[0].raw_state = {}; });
+ reject((value) => { value.items[0].value = 'x'.repeat(256 * 1024); });
+ reject((value) => { value.items = Array.from({ length: 51 }, () => details.items[0]); value.total_count = 51; });
+ reject((value) => { value.total_count = 0; });
+ for (const total of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '5001', null]) {
+ reject((value) => { value.total_count = total; });
+ }
+ reject((value) => { value.next_cursor = 42; });
+ const nested = projection.nodes.find((node) => node.id === 'body-2');
+ const frames = [0, 1, 2].map((index) => ({ loop_id: `each-${index}`, item_id: 'a'.repeat(64), index }));
+ assert.deepEqual(inspection.inspectionNodePath(nested, frames), frames);
+ assert.equal(inspection.inspectionNodePath(nested, frames.slice(1)), null);
+ assert.equal(inspection.inspectionNodePath(nested, []), null);
+ assert.deepEqual(inspection.inspectionNodePath(projection.nodes[0], frames), []);
+} else if (input.check === 'bindings') {
+ const nodes = new Map(projection.nodes.map((node) => [node.id, node]));
+ frozen(projection);
+ for (const entry of input.binding_cases) {
+ const node = nodes.get(entry.node_id);
+ assert.ok(node);
+ const details = inspection.parseWorkflowInspectionDetails(
+ entry.details, projection.source, node.id, entry.section,
+ );
+ const before = JSON.stringify(details);
+ frozen(details);
+ const bindings = inspection.workflowInspectionBindings(node, details);
+ assert.deepEqual(bindings, entry.expected, `${node.id}: ${entry.section}`);
+ assert.ok(bindings.every((binding) => nodes.has(binding.sourceId)));
+ assert.deepEqual(inspection.workflowInspectionBindings(node, null), []);
+ assert.deepEqual(inspection.workflowInspectionBindings(node, { ...details, node_id: 'another-node' }), []);
+ assert.equal(JSON.stringify(details), before);
+ }
+ const malformed = [
+ ['decision-join', 'outputs', (value) => ({ ...value, else: { node_id: null, output: 'json' } })],
+ ['review-loop', 'state', (value) => ({ ...value, next: 2 })],
+ ['all-records', 'configuration', (value) => ({ ...value, private_snapshot: {} })],
+ ['update', 'inputs', (value) => ({ ...value, source: { ...value.source, kind: 'future-source' } })],
+ ];
+ for (const [id, section, change] of malformed) {
+ const details = input.binding_cases.find((entry) => entry.node_id === id && entry.section === section).details;
+ const item = details.items[0];
+ assert.deepEqual(inspection.workflowInspectionBindings(nodes.get(id), {
+ ...details, items: [{ ...item, value: change(item.value) }],
+ }), []);
+ }
+} else if (input.check === 'selection') {
+ const parsed = input.selection_pages.map((page) => inspection.parseWorkflowInspectionDetails(
+ page, projection.source, 'each-0', 'selection',
+ ));
+ for (const page of parsed) {
+ assert.equal(page.total_count, 5001, '5000 documents plus the Iterable row must remain inspectable.');
+ assert.equal(page.items.length, 50);
+ assert.ok(page.next_cursor);
+ }
+ assert.equal(parsed[0].items[0].label, 'Iterable');
+ assert.equal(parsed[0].items[1].value.document_id, 'selection-document-0000');
+ assert.equal(parsed[1].items[0].value.document_id, 'selection-document-0049');
+ assert.equal(parsed[1].items[49].value.document_id, 'selection-document-0098');
+ const firstIds = new Set(parsed[0].items.slice(1).map((item) => item.value.document_id));
+ assert.ok(parsed[1].items.every((item) => !firstIds.has(item.value.document_id)));
+ const tooMany = structuredClone(input.selection_pages[0]);
+ tooMany.items.push(structuredClone(tooMany.items[1]));
+ assert.throws(() => inspection.parseWorkflowInspectionDetails(tooMany, projection.source, 'each-0', 'selection'));
+ const tooLarge = structuredClone(input.selection_pages[0]);
+ tooLarge.items[1].value.document_id = 'x'.repeat(256 * 1024);
+ assert.throws(() => inspection.parseWorkflowInspectionDetails(tooLarge, projection.source, 'each-0', 'selection'));
+ const references = input.reference_pages.map((page) => inspection.parseWorkflowInspectionDetails(
+ page, projection.source, page.node_id, 'selection',
+ ));
+ assert.deepEqual(references.map((page) => [page.node_id, page.items.length, page.total_count]), [
+ ['root', 50, 60], ['root', 10, 60], ['seed', 2, 2],
+ ]);
+ assert.equal(references[1].next_cursor, null);
+ assert.deepEqual(references[2].items.map((item) => item.label), ['reference_0', 'reference_59']);
+} else if (input.check === 'transport') {
+ const calls = [];
+ let response = projection;
+ globalThis.fetch = async (url, options) => {
+ calls.push({ url: new URL(url, 'http://simplechat.test'), ...options });
+ return new Response(JSON.stringify(response), { headers: { 'Content-Type': 'application/json' } });
+ };
+ const controller = new AbortController();
+ await inspection.fetchWorkflowFlowProjection(personal, target, controller.signal);
+ assert.equal(calls.at(-1).method, 'GET');
+ assert.equal(calls.at(-1).url.pathname, `/api/user/workflows/${target.workflowId}/flow`);
+ assert.equal(calls.at(-1).signal, controller.signal);
+ const source = { ...projection.source, kind: 'run', scope_type: 'group', scope_id: 'group/alpha',
+ run_id: 'frozen/run', snapshot_sha256: 'a'.repeat(64) };
+ const group = { type: 'group', groupId: source.scope_id };
+ const selectedRun = { kind: 'run', workflowId: target.workflowId, runId: source.run_id };
+ response = { ...projection, source };
+ await inspection.fetchWorkflowFlowProjection(group, selectedRun, controller.signal);
+ assert.equal(calls.at(-1).url.pathname, `/api/group/workflows/${target.workflowId}/runs/frozen%2Frun/flow`);
+ assert.equal(calls.at(-1).url.searchParams.get('group_id'), 'group/alpha');
+ response = { projection_version: 1, source, node_id: 'seed', section: 'inputs', items: [], total_count: 0, next_cursor: null };
+ await inspection.fetchWorkflowInspectionDetails(group, selectedRun, source, 'seed', 'inputs', 'exact-page', controller.signal);
+ assert.equal(calls.at(-1).url.searchParams.get('node_id'), 'seed');
+ assert.equal(calls.at(-1).url.searchParams.get('section'), 'inputs');
+ assert.equal(calls.at(-1).url.searchParams.get('revision'), source.definition_revision);
+ assert.equal(calls.at(-1).url.searchParams.get('cursor'), 'exact-page');
+ assert.equal(calls.at(-1).url.searchParams.get('limit'), '50');
+ assert.equal(calls.at(-1).url.searchParams.get('group_id'), 'group/alpha');
+ const draft = { kind: 'draft', definition: input.definition };
+ response = input.preview;
+ await inspection.fetchWorkflowFlowProjection(personal, draft, controller.signal);
+ assert.equal(calls.at(-1).method, 'POST');
+ assert.equal(calls.at(-1).url.pathname, '/api/user/workflows/flow-preview');
+ assert.deepEqual(JSON.parse(calls.at(-1).body), { definition: input.definition });
+ response = input.preview_details;
+ await inspection.fetchWorkflowInspectionDetails(
+ personal, draft, input.preview.source, 'seed', 'configuration', null, controller.signal,
+ );
+ assert.equal(calls.at(-1).method, 'POST');
+ assert.equal(calls.at(-1).url.pathname, '/api/user/workflows/flow-preview');
+ assert.deepEqual(JSON.parse(calls.at(-1).body), {
+ definition: input.definition, node_id: 'seed', section: 'configuration',
+ revision: input.preview.source.definition_revision, limit: 50,
+ });
+ assert.ok(calls.every((call) => call.signal === controller.signal));
+ assert.equal(calls.length, 5, 'Inspection must not scan history or trigger workflow writes.');
+}
+console.log(JSON.stringify({ check: input.check, nodes: projection.nodes.length }));
+"""
+
+
+def run_typescript(script, payload, label):
+ result = subprocess.run(
+ ["node", "--input-type=module", "--eval", script, str(ROOT)],
+ input=json.dumps(payload), cwd=ROOT, text=True, capture_output=True, timeout=30, check=False,
+ )
+ assert result.returncode == 0, f"Production TypeScript {label} check failed:\n{result.stdout}\n{result.stderr}"
+ return json.loads(result.stdout)
+
+
+@pytest.mark.parametrize("check", ["layout", "collapse", "guards", "details", "transport"])
+def test_production_projection_layout_and_guard(check):
+ definition = maximum_structured_definition()
+ original = copy.deepcopy(definition)
+ compiled = compile_workflow_flow(definition)
+ helpers = inspection
+ projection = helpers.workflow_flow_inspection(definition)
+ assert len(compiled["nodes"]) + len(compiled["regions"]) == 256
+ assert len(projection["nodes"]) == 256
+ assert set(node["id"] for node in projection["nodes"]) == set(compiled["nodes"]) | set(compiled["regions"])
+ assert max(len(node["loop_ids"]) for node in projection["nodes"]) == 3
+ depths = []
+ for region_id in compiled["regions"]:
+ depth = 1
+ parent = compiled["regions"][region_id]["parent"]
+ while parent is not None:
+ depth += 1
+ parent = compiled["regions"][compiled["nodes"][parent]["region_id"]]["parent"]
+ depths.append(depth)
+ assert max(depths) == 4
+ assert "instructions" not in json.dumps(projection)
+ assert "fictional-source" not in json.dumps(projection)
+ preview = helpers.preview_workflow_flow(definition, user_id=definition["user_id"])
+ preview_details = helpers.preview_workflow_flow(
+ definition, user_id=definition["user_id"], node_id="seed", section="configuration",
+ revision=preview["source"]["definition_revision"], limit=50,
+ )
+ assert definition == original
+ assert workflow_definition_revision(definition) == workflow_definition_revision(original)
+ result = run_typescript(NODE_CHECKS, {
+ "check": check, "projection": projection, "preview": preview,
+ "preview_details": preview_details, "definition": definition,
+ "execution_node_ids": [*compiled["nodes"], compiled["flow"]["id"]],
+ }, check)
+ assert result["nodes"] == 256
+
+
+def test_binding_helper_uses_actual_compiled_inputs_and_boundary_exports():
+ definition = binding_structured_definition()
+ original = copy.deepcopy(definition)
+ helpers = inspection
+ projection = helpers.workflow_flow_inspection(definition)
+ cases = [
+ ("choose", "inputs", [{"sourceId": "seed", "label": "decision: json"}]),
+ ("update", "inputs", [
+ {"sourceId": "review-loop", "label": "current_review: json"},
+ {"sourceId": "seed", "label": "seed_decision: json"},
+ ]),
+ ("process-document", "inputs", [
+ {"sourceId": "each", "label": "document: json"},
+ {"sourceId": "review-loop", "label": "latest_review: json"},
+ ]),
+ ("decision-join", "outputs", [
+ {"sourceId": "accepted", "label": "review (Then): json"},
+ {"sourceId": "reviewed", "label": "review (Else): json"},
+ ]),
+ ("review-loop", "state", [
+ {"sourceId": "decision-join", "label": "review initial: json"},
+ {"sourceId": "review-body", "label": "review next: body export next_review"},
+ ]),
+ ("review-body", "outputs", [{"sourceId": "update", "label": "next_review: json"}]),
+ ("each", "outputs", [{"sourceId": "process-document", "label": "rows: records"}]),
+ ("each-body", "outputs", [{"sourceId": "process-document", "label": "rows: records"}]),
+ ("root", "outputs", [{"sourceId": "all-records", "label": "results: records"}]),
+ ("all-records", "configuration", [{"sourceId": "each", "label": "Every frozen item: rows"}]),
+ ("seed", "configuration", []),
+ ("seed", "outputs", []),
+ ("seed", "selection", []),
+ ("root", "selection", []),
+ ]
+ details = [{
+ "node_id": node_id, "section": section, "expected": expected,
+ "details": helpers.workflow_flow_inspection(
+ definition, node_id=node_id, section=section, revision=projection["source"]["definition_revision"], limit=50,
+ ),
+ } for node_id, section, expected in cases]
+ result = run_typescript(NODE_CHECKS, {
+ "check": "bindings", "projection": projection, "binding_cases": details,
+ }, "compiler-derived binding boundaries")
+ assert result["nodes"] == len(projection["nodes"])
+ assert definition == original
+
+
+def test_source_selection_accepts_5001_total_but_keeps_detail_pages_bounded():
+ definition = maximum_structured_definition()
+ definition["flow"]["nodes"][-1]["iterable"]["documents"] = [{
+ "document_id": f"selection-document-{index:04d}", "scope_type": "personal",
+ } for index in range(5000)]
+ definition["reference_inputs"] = [{
+ "id": f"ref-{index}", "name": f"reference_{index}", "document_id": f"reference-document-{index}",
+ "scope_type": "personal",
+ } for index in range(60)]
+ definition["tasks"][0]["reference_ids"] = ["ref-0", "ref-59"]
+ original = copy.deepcopy(definition)
+ helpers = inspection
+ projection = helpers.workflow_flow_inspection(definition)
+ revision = projection["source"]["definition_revision"]
+
+ def page(node_id, cursor=None):
+ return helpers.workflow_flow_inspection(
+ definition, node_id=node_id, section="selection", revision=revision, cursor=cursor, limit=50,
+ )
+
+ selection = page("each-0")
+ references = page("root")
+ assert "selection-document-" not in json.dumps(projection)
+ assert "reference-document-" not in json.dumps(projection)
+ result = run_typescript(NODE_CHECKS, {
+ "check": "selection", "projection": projection,
+ "selection_pages": [selection, page("each-0", selection["next_cursor"])],
+ "reference_pages": [references, page("root", references["next_cursor"]), page("seed")],
+ }, "bounded source selections")
+ assert result["nodes"] == 256
+ assert definition == original
+
+
+def test_production_inspection_import_and_details_require_no_config_or_network():
+ definition = binding_structured_definition()
+ next(task for task in definition["tasks"] if task["id"] == "seed-task")["document_action"] = {
+ "type": "analyze", "analysis_options": {},
+ }
+ script = r"""
+import importlib.abc
+import json
+import sys
+
+config_attempts = []
+network_attempts = []
+
+class RejectApplicationConfig(importlib.abc.MetaPathFinder):
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname == "config" or fullname.startswith("config."):
+ config_attempts.append(fullname)
+ raise AssertionError("Pure Flow inspection imported application configuration.")
+ return None
+
+def reject_network(event, arguments):
+ if event in {"socket.connect", "socket.getaddrinfo", "http.client.connect"}:
+ network_attempts.append(event)
+ raise AssertionError(f"Pure Flow inspection attempted network access: {event}")
+
+assert "config" not in sys.modules
+sys.meta_path.insert(0, RejectApplicationConfig())
+sys.addaudithook(reject_network)
+sys.path.insert(0, sys.argv[1])
+
+# The production import deliberately follows the offline guards and path setup.
+from functions_workflow_inspection import FLOW_DETAIL_SECTIONS, preview_workflow_flow, workflow_flow_inspection
+
+definition = json.load(sys.stdin)
+before = json.dumps(definition, sort_keys=True)
+new_draft = json.loads(before)
+new_draft.pop("id")
+checked = 0
+nodes = 0
+for authored, draft in ((definition, False), (definition, True), (new_draft, True)):
+ reader = preview_workflow_flow if draft else workflow_flow_inspection
+ options = {"user_id": authored["user_id"]} if draft else {}
+ projection = reader(authored, **options)
+ nodes = len(projection["nodes"])
+ if draft:
+ assert projection["source"]["definition_revision"].startswith("DRAFT:")
+ assert projection["source"]["workflow_id"] == authored.get("id")
+ for node in projection["nodes"]:
+ for section in FLOW_DETAIL_SECTIONS:
+ detail = reader(
+ authored, node_id=node["id"], section=section,
+ revision=projection["source"]["definition_revision"], limit=50, **options,
+ )
+ assert detail["source"] == projection["source"]
+ assert detail["node_id"] == node["id"] and detail["section"] == section
+ assert len(detail["items"]) <= 50
+ checked += 1
+assert json.dumps(definition, sort_keys=True) == before
+assert not config_attempts and not network_attempts
+assert "config" not in sys.modules
+print(json.dumps({"details": checked, "nodes": nodes, "sections": len(FLOW_DETAIL_SECTIONS)}))
+"""
+ result = subprocess.run(
+ [sys.executable, "-I", "-c", script, str(APP_ROOT)],
+ input=json.dumps(definition), cwd=ROOT, text=True, capture_output=True, timeout=90, check=False,
+ )
+ assert result.returncode == 0, f"Pure inspection crossed its import/read boundary:\n{result.stdout}\n{result.stderr}"
+ checked = json.loads(result.stdout)
+ assert checked["sections"] == 6
+ assert checked["details"] == 3 * 6 * checked["nodes"]
+
+
+def test_existing_list_save_payload_can_preview_preserved_metadata_without_a_write():
+ definition = maximum_structured_definition()
+ definition.update(
+ metadata={"legacy": {"retained": False}},
+ alert_settings={"owner_on_failure": True},
+ publication_options={"publish_to_public_workspace": False},
+ )
+ definition["definition_revision"] = workflow_definition_revision(definition)
+ original = copy.deepcopy(definition)
+ script = r"""
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+const root = process.argv[1];
+await import(pathToFileURL(path.join(root, 'functional_tests', 'test_support', 'tsResolve.mjs')));
+const { normalizeWorkflowDefinition, workflowForSave } = await import(
+ pathToFileURL(path.join(root, 'application', 'v2_ui', 'src', 'lib', 'workflowEditor.ts')),
+);
+const input = JSON.parse(readFileSync(0, 'utf8'));
+const scope = { type: 'personal' };
+const original = normalizeWorkflowDefinition(input, scope);
+const payload = workflowForSave(original, original, scope);
+assert.deepEqual(payload.metadata, input.metadata);
+assert.deepEqual(payload.alert_settings, input.alert_settings);
+assert.deepEqual(payload.publication_options, input.publication_options);
+assert.equal(payload.definition_revision, input.definition_revision);
+console.log(JSON.stringify(payload));
+"""
+ payload = run_typescript(script, definition, "preserved List save payload")
+ preview = inspection.preview_workflow_flow(payload, user_id=definition["user_id"])
+ assert preview["source"]["kind"] == "draft"
+ assert preview["source"]["definition_revision"].startswith("DRAFT:")
+ assert len(preview["nodes"]) == 256
+ assert payload["definition_revision"] == original["definition_revision"]
+ assert definition == original
+ assert "metadata" not in preview and "alert_settings" not in preview and "publication_options" not in preview
+
+
+if __name__ == "__main__":
+ raise SystemExit(pytest.main([__file__, "-q"]))
diff --git a/functional_tests/test_workflow_flow_semantics.js b/functional_tests/test_workflow_flow_semantics.js
new file mode 100644
index 000000000..b1b5e3501
--- /dev/null
+++ b/functional_tests/test_workflow_flow_semantics.js
@@ -0,0 +1,88 @@
+// test_workflow_flow_semantics.js
+/*
+Functional tests for read-only Flow semantic presentation.
+Version: 0.261.121
+Implemented in: 0.261.121
+
+Executes the real TypeScript helpers without a browser or backend.
+*/
+
+const assert = require('node:assert/strict');
+const path = require('node:path');
+const { pathToFileURL } = require('node:url');
+const { before, test } = require('node:test');
+
+const root = path.resolve(__dirname, '..');
+let flow;
+let inspection;
+
+before(async () => {
+ // Register the existing TypeScript loader before importing production ES modules.
+ await import(pathToFileURL(path.join(__dirname, 'test_support', 'tsResolve.mjs')));
+ const moduleUrl = (name) => pathToFileURL(path.join(root, 'application', 'v2_ui', 'src', 'lib', `${name}.ts`));
+ flow = await import(moduleUrl('workflowFlow'));
+ inspection = await import(moduleUrl('workflowInspection'));
+});
+
+function node(id, kind, loopIds = []) {
+ return {
+ id, kind, label: id, parent_id: id === 'root' ? null : 'root', region_id: 'root',
+ order: 0, loop_ids: loopIds, child_region_ids: [], inputs_count: 0, outputs_count: 0,
+ has_condition: false,
+ };
+}
+
+test('only real nodes and the root expose execution lookup, not grouping regions', () => {
+ assert.equal(inspection.inspectionNodeHasExecution(node('root', 'region'), 'root'), true);
+ for (const id of ['then', 'else', 'body', 'constructor']) {
+ assert.equal(inspection.inspectionNodeHasExecution(node(id, 'region'), 'root'), false);
+ }
+ for (const kind of ['task', 'if', 'join', 'route', 'for_each', 'repeat_until', 'collect']) {
+ assert.equal(inspection.inspectionNodeHasExecution(node('operation', kind), 'root'), true);
+ }
+});
+
+test('mixed overlay and gate paths compare values, not JSON property insertion order', () => {
+ const selected = [
+ { loop_id: 'outer', item_id: 'frozen-item', index: 4999 },
+ { loop_id: 'repeat', iteration: 1000 },
+ { loop_id: 'inner', item_id: 'nested-item', index: 0 },
+ ];
+ const reordered = [
+ { index: 4999, item_id: 'frozen-item', loop_id: 'outer' },
+ { iteration: 1000, loop_id: 'repeat' },
+ { item_id: 'nested-item', index: 0, loop_id: 'inner' },
+ ];
+ const body = node('work', 'task', ['outer', 'repeat', 'inner']);
+ assert.equal(inspection.inspectionNodeMatchesPath(body, selected, reordered), true);
+ assert.equal(inspection.inspectionNodeMatchesPath(body, selected, [
+ { ...reordered[0], item_id: 'another-item' }, ...reordered.slice(1),
+ ]), false);
+ assert.equal(inspection.inspectionNodeMatchesPath(body, selected, [
+ reordered[0], { ...reordered[1], iteration: 999 }, reordered[2],
+ ]), false);
+ assert.equal(inspection.inspectionNodeMatchesPath(body, [], reordered), false);
+ assert.equal(inspection.inspectionNodeMatchesPath(body, selected), false);
+ assert.equal(inspection.inspectionNodeMatchesPath(node('outside', 'task'), selected, []), true);
+ assert.equal(inspection.inspectionNodeMatchesPath(node('outer-work', 'task', ['outer']), selected, reordered.slice(0, 1)), true);
+});
+
+test('condition summaries preserve nested AND/OR grouping without changing predicates', () => {
+ const comparison = (field, literal) => ({
+ op: 'eq', left: { input: 'review', path: `/${field}` }, right: { literal },
+ });
+ const condition = {
+ op: 'all',
+ conditions: [
+ comparison('ready', true),
+ { op: 'any', conditions: [comparison('score', 0), comparison('reason', null)] },
+ ],
+ };
+ const original = structuredClone(condition);
+ assert.equal(flow.isFlowPredicate(condition), true);
+ assert.equal(flow.predicateSummary(condition),
+ 'review.ready equals true AND (review.score equals 0 OR review.reason equals null)');
+ assert.equal(flow.predicateSummary({ op: 'not', condition }),
+ 'NOT (review.ready equals true AND (review.score equals 0 OR review.reason equals null))');
+ assert.deepEqual(condition, original);
+});
diff --git a/ui_tests/fixtures/workflow_flow.py b/ui_tests/fixtures/workflow_flow.py
new file mode 100644
index 000000000..5ca939ddc
--- /dev/null
+++ b/ui_tests/fixtures/workflow_flow.py
@@ -0,0 +1,586 @@
+# workflow_flow.py
+"""
+Closed production-bundle fixtures for read-only M5A Flow inspection.
+Version: 0.261.121
+Implemented in: 0.261.121
+
+The shared fixture serves only local static assets and fictional API responses.
+Topology, preview, details and execution identities use the real pure Python
+helpers. Every mutation other than the data-only preview POST is rejected.
+"""
+
+import copy
+import hashlib
+import json
+import os
+import re
+import sys
+from pathlib import Path
+from urllib.parse import parse_qs, urlsplit
+
+import pytest
+
+from ui_tests.fixtures.workflow_control_definitions import flow_binding, structured_workflow_record
+from ui_tests.fixtures.workflow_editor import GROUP_ID, SECOND_GROUP_ID, WORKFLOW_ID, workflow_record
+from ui_tests.fixtures.workflow_loops import loop_item, record_contract
+from ui_tests.fixtures.workflow_repeat_until import WorkflowRepeatFixture, bounded_pages, state_binding, state_contract
+from ui_tests.fixtures.workspace_authoring import OWNER_ID
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "application" / "single_app"))
+
+# Production imports follow the application path setup and initialize no clients.
+import functions_workflow_inspection as inspection
+from functions_workflow_definitions import WorkflowDefinitionConflict, WorkflowDefinitionError, workflow_definition_revision
+from functions_workflow_flow import compile_workflow_flow
+from functions_workflow_identity import canonical_digest, workflow_execution_id
+
+
+FLOW_WORKFLOW_ID = "flow-inspection-workflow"
+FLOW_RUN_ID = "flow-frozen-run"
+FLOW_NAME = "Read-only branch review"
+MIXED_WORKFLOW_ID = "flow-mixed-loops"
+MIXED_RUN_ID = "flow-mixed-run"
+MIXED_NAME = "Nested instance review"
+MALICIOUS_LABEL = ' '
+
+
+def flow_workflow_record(*, group_id=None, name=FLOW_NAME):
+ record = structured_workflow_record(
+ FLOW_WORKFLOW_ID, name=name, user_id=OWNER_ID,
+ **({"group_id": group_id} if group_id else {}),
+ )
+ # Catalogue order intentionally disagrees with executable region order.
+ record["tasks"].reverse()
+ for index, task in enumerate(record["tasks"], 1):
+ task["order"] = index
+ record["definition_revision"] = workflow_definition_revision(record)
+ return record
+
+
+def mixed_workflow_record(kinds=("repeat_until", "for_each", "repeat_until"), *, group_id=None):
+ """Real depth-four compiler syntax, never one node per item or lifetime round."""
+ seed_json = {
+ "id": "seed-decision-task", "name": "Seed decision", "type": "instructions",
+ "instructions": "Return the typed initial decision; never execute this fixture.",
+ "order": 1, "runner": {"type": "inherit"}, "inputs": [], "reference_ids": [],
+ "document_action": {"type": "none"}, "output_contract": state_contract("json"),
+ }
+ seed_records = {
+ **copy.deepcopy(seed_json), "id": "seed-records-task", "name": "Seed findings", "order": 2,
+ "output_contract": record_contract(),
+ }
+ leaf = {
+ **copy.deepcopy(seed_records), "id": "inspect-record-task", "name": "Inspect exact finding", "order": 3,
+ "instructions": "FROZEN_INSTRUCTIONS: preserve false, zero, null, duplicates and complete records.",
+ "inputs": [],
+ }
+ loop_ids = [f"loop-{index}" for index in range(len(kinds))]
+ for kind, loop_id in zip(kinds, loop_ids):
+ leaf["inputs"].append(
+ loop_item(f"item_{loop_id.replace('-', '_')}", loop_id) if kind == "for_each"
+ else state_binding(f"state_{loop_id.replace('-', '_')}", "review", "json", loop_id)
+ )
+
+ def build(index):
+ if index == len(kinds):
+ return [{"id": "inspect-record", "kind": "task", "task_id": leaf["id"]}], "inspect-record", "records"
+ children, producer_id, output = build(index + 1)
+ loop_id, kind = loop_ids[index], kinds[index]
+ body = {
+ "id": f"body-{index}", "nodes": children,
+ "outputs": [flow_binding("findings", producer_id, output, kind="records")],
+ }
+ if kind == "for_each":
+ loop = {
+ "id": loop_id, "kind": kind, "max_items": 5000, "item_key": "source_identity",
+ "inputs": [flow_binding("rows", "seed-records", "records", kind="records")],
+ "iterable": {"kind": "input", "name": "rows"}, "body": body,
+ }
+ collect_id = f"collect-{index}"
+ return [loop, {
+ "id": collect_id, "kind": "collect", "source": {"loop_id": loop_id, "output": "findings"},
+ "output_contract": record_contract(),
+ }], collect_id, "records"
+ body["outputs"].append(state_binding("next_review", "review", "json", loop_id))
+ loop = {
+ "id": loop_id, "kind": kind, "max_iterations": 1000 if index == 0 else 1,
+ "state": [{
+ "name": "review", "initial": flow_binding("review", "seed-decision", "json")["source"],
+ "next": "next_review", "output_contract": state_contract("json"),
+ }],
+ "body": body,
+ "until": {"op": "eq", "left": {"input": "review", "path": "/ready"}, "right": {"literal": True}},
+ "exports": [{"name": "findings", "output": "findings"}, {"name": "review", "output": "next_review"}],
+ }
+ return [loop], loop_id, "findings"
+
+ children, producer, output = build(0)
+ record = workflow_record(
+ MIXED_WORKFLOW_ID, name=MIXED_NAME, definition_version=3, durable_execution=True,
+ user_id=OWNER_ID, tasks=[seed_records, leaf, seed_json], reference_inputs=[],
+ chat_capabilities_enabled=False, limits={"max_executions": 5000, "deadline_seconds": 86400},
+ flow={
+ "id": "root",
+ "nodes": [
+ {"id": "seed-decision", "kind": "task", "task_id": seed_json["id"]},
+ {"id": "seed-records", "kind": "task", "task_id": seed_records["id"]}, *children,
+ ],
+ "outputs": [flow_binding("findings", producer, output, kind="records")],
+ },
+ **({"group_id": group_id} if group_id else {}),
+ )
+ compile_workflow_flow(record)
+ record["definition_revision"] = workflow_definition_revision(record)
+ return record
+
+
+class WorkflowFlowFixture(WorkflowRepeatFixture):
+ """Keep all existing history fixtures unchanged; add only closed Flow reads."""
+
+ def __init__(self, page):
+ super().__init__(page)
+ self.inspection = inspection
+ self.flow_definitions = {}
+ self.exact_executions = {}
+ self.loop_pages = {}
+ self.flow_attempts = {}
+ self.flow_records = {}
+ self.flow_payloads = []
+ self.held_flow_responses = []
+ self.flow_response_gates = []
+ self.group_can_manage = True
+ self.legacy_workflow = workflow_record(
+ "flow-legacy-v1", name="Legacy version one", definition_version=1,
+ tasks=[], task_prompt="Keep the legacy prompt unchanged.",
+ )
+ self.personal_workflows = {
+ FLOW_WORKFLOW_ID: flow_workflow_record(),
+ WORKFLOW_ID: self.personal_workflows[WORKFLOW_ID],
+ self.legacy_workflow["id"]: self.legacy_workflow,
+ }
+ self.group_workflows = {
+ GROUP_ID: {FLOW_WORKFLOW_ID: flow_workflow_record(group_id=GROUP_ID, name="Alpha read-only Flow")},
+ SECOND_GROUP_ID: {FLOW_WORKFLOW_ID: flow_workflow_record(group_id=SECOND_GROUP_ID, name="Beta read-only Flow")},
+ }
+ self._seed_branch_history()
+ self._seed_group_history()
+ self.seed_mixed()
+
+ @property
+ def preview_requests(self):
+ return [entry for entry in self.requests if entry.path.endswith("/flow-preview")]
+
+ @property
+ def topology_requests(self):
+ return [
+ entry for entry in self.requests
+ if entry.path.endswith("/flow") and "node_id" not in entry.query
+ ]
+
+ @property
+ def detail_requests(self):
+ return [entry for entry in self.requests if entry.path.endswith("/flow") and "node_id" in entry.query]
+
+ @property
+ def exact_requests(self):
+ return [entry for entry in self.requests if entry.path.endswith("/executions") and "node_id" in entry.query]
+
+ @property
+ def evidence_requests(self):
+ return [
+ entry for entry in self.requests
+ if re.search(r"/(?:attempts|result|records|provenance|iterations|state|items)(?:/|$)", entry.path)
+ ]
+
+ def _execution_node(self, key, node_id):
+ compiled = compile_workflow_flow(self.flow_definitions[key])
+ if node_id == compiled["flow"]["id"]:
+ return {"node": {"id": node_id, "kind": "root"}, "region_id": node_id}
+ assert node_id in compiled["nodes"], f"The selected structural node {node_id} has no execution identity."
+ return compiled["nodes"][node_id]
+
+ def execution_for(self, workflow_id, run_id, node_id, path, *, scope="user"):
+ key = (scope, workflow_id, run_id)
+ self._execution_node(key, node_id)
+ frozen = self.flow_definitions[key]
+ return workflow_execution_id(frozen, run_id, node_id, path)
+
+ def _add_execution(self, key, node_id, path, *, state="completed", attempt=1, records=None):
+ frozen = self.flow_definitions[key]
+ compiled_node = self._execution_node(key, node_id)
+ node = compiled_node["node"]
+ execution_id = workflow_execution_id(frozen, key[2], node_id, path)
+ entry = {
+ "execution_id": execution_id, "node_id": node_id, "node_kind": node["kind"],
+ "region_id": compiled_node["region_id"],
+ "iteration_path": copy.deepcopy(path), "state": state, "attempt": attempt,
+ }
+ if node["kind"] == "task":
+ entry["task_id"] = node["task_id"]
+ if records is not None:
+ identity = {
+ "workflow_id": key[1], "run_id": key[2], "execution_id": execution_id,
+ "node_id": node_id, "task_id": node["task_id"], "iteration_path": copy.deepcopy(path), "attempt": attempt,
+ }
+ reference = {
+ "storage": "cosmos", "schema_version": 1, "size_bytes": 1024,
+ "sha256": canonical_digest(records), "chunk_count": 1,
+ }
+ entry["workflow_result"] = {
+ "contract_version": "workflow-result-v2", "producer": identity,
+ "authoritative_output": "records", "outputs": {"records": {"kind": "records", "result_ref": reference}},
+ "result_ref": reference,
+ }
+ entry["workflow_validation"] = {
+ "version": 1, "status": "valid", "eligible": True, "reason_codes": [], "counts": {"records": len(records)},
+ }
+ self.flow_records[(*key, execution_id, attempt)] = copy.deepcopy(records)
+ self.exact_executions[(*key, execution_id)] = copy.deepcopy(entry)
+ attempts = []
+ if attempt > 1:
+ attempts.append({
+ "execution_id": execution_id, "node_id": node_id, "node_kind": node["kind"],
+ "iteration_path": copy.deepcopy(path), "attempt": 1, "state": "failed",
+ **({"task_id": node["task_id"]} if node["kind"] == "task" else {}),
+ })
+ if attempt > 0:
+ attempts.append(copy.deepcopy(entry))
+ self.flow_attempts[(*key, execution_id)] = attempts
+ return entry
+
+ def _seed_branch_history(self):
+ key = ("user", FLOW_WORKFLOW_ID, FLOW_RUN_ID)
+ frozen = copy.deepcopy(self.personal_workflows[FLOW_WORKFLOW_ID])
+ task = next(task for task in frozen["tasks"] if task["id"] == "evaluate")
+ task["name"] = "Frozen evaluation"
+ task["instructions"] = "FROZEN_INSTRUCTIONS: evaluate the original admitted decision."
+ frozen["definition_revision"] = workflow_definition_revision(frozen)
+ self.flow_definitions[key] = frozen
+ first = self._add_execution(key, "evaluate", [], attempt=2)
+ skipped = self._add_execution(key, "review", [], state="skipped", attempt=0)
+ skipped["reason_code"] = "branch_not_selected"
+ self.exact_executions[(*key, skipped["execution_id"])] = skipped
+ self.execution_pages[key] = {
+ "": {"items": [first], "next_cursor": "later-unloaded-executions"},
+ "later-unloaded-executions": {"items": [skipped], "next_cursor": None},
+ }
+ self.decision_pages[key] = bounded_pages([], 50)
+ self.workflow_runs[FLOW_WORKFLOW_ID] = [{
+ "id": FLOW_RUN_ID, "workflow_id": FLOW_WORKFLOW_ID, "definition_version": 3,
+ "definition_revision": frozen["definition_revision"], "durable_execution": True,
+ "status": "completed", "started_at": "2026-09-19T12:00:00Z",
+ }]
+ self.workflow_runtimes[key] = {
+ "schema_version": 2, "version": 12, "state": "completed",
+ "memory": {"execution_count": 2, "decision_count": 0},
+ }
+ self.runtime_can_decide[key] = False
+ live = next(task for task in self.personal_workflows[FLOW_WORKFLOW_ID]["tasks"] if task["id"] == "evaluate")
+ live["name"] = "Later live evaluation"
+ live["instructions"] = "LIVE_ONLY_INSTRUCTIONS: a different revision reuses the same node id."
+ self.personal_workflows[FLOW_WORKFLOW_ID]["definition_revision"] = workflow_definition_revision(
+ self.personal_workflows[FLOW_WORKFLOW_ID],
+ )
+
+ def _seed_group_history(self):
+ key = ("group", FLOW_WORKFLOW_ID, FLOW_RUN_ID)
+ frozen = copy.deepcopy(self.group_workflows[GROUP_ID][FLOW_WORKFLOW_ID])
+ task = next(task for task in frozen["tasks"] if task["id"] == "evaluate")
+ task["name"] = "Frozen group evaluation"
+ task["instructions"] = "FROZEN_GROUP_INSTRUCTIONS: the exact authorized group revision."
+ frozen["definition_revision"] = workflow_definition_revision(frozen)
+ self.flow_definitions[key] = frozen
+ entry = self._add_execution(key, "evaluate", [], attempt=2)
+ self.execution_pages[key] = bounded_pages([entry], 50)
+ self.decision_pages[key] = bounded_pages([], 50)
+ self.workflow_runtimes[key] = {
+ "schema_version": 2, "version": 7, "state": "completed",
+ "memory": {"execution_count": 1, "decision_count": 0},
+ }
+ self.runtime_can_decide[key] = False
+
+ def seed_mixed(self, kinds=("repeat_until", "for_each", "repeat_until")):
+ definition = mixed_workflow_record(kinds)
+ self.personal_workflows[MIXED_WORKFLOW_ID] = copy.deepcopy(definition)
+ key = ("user", MIXED_WORKFLOW_ID, MIXED_RUN_ID)
+ for collection in (self.exact_executions, self.loop_pages, self.flow_attempts, self.flow_records):
+ for identity in tuple(collection):
+ if identity[:3] == key:
+ del collection[identity]
+ self.flow_definitions[key] = copy.deepcopy(definition)
+ self.workflow_runs[MIXED_WORKFLOW_ID] = [{
+ "id": MIXED_RUN_ID, "workflow_id": MIXED_WORKFLOW_ID, "definition_version": 3,
+ "definition_revision": definition["definition_revision"], "durable_execution": True,
+ "status": "completed", "started_at": "2026-09-19T12:00:00Z",
+ }]
+ self.workflow_runtimes[key] = {
+ "schema_version": 2, "version": 20, "state": "completed",
+ "memory": {"execution_count": 4010, "decision_count": 1001},
+ }
+ self.runtime_can_decide[key] = False
+ self.decision_pages[key] = bounded_pages([], 50)
+ self._add_execution(key, "seed-decision", [])
+ path = []
+ self.mixed_frames = []
+ outer = None
+ for index, kind in enumerate(kinds):
+ node_id = f"loop-{index}"
+ execution = self._add_execution(key, node_id, path)
+ outer = outer or execution
+ execution_id = execution["execution_id"]
+ if kind == "repeat_until":
+ iteration = 1000 if index == kinds.index("repeat_until") else 0
+ frame = {"loop_id": node_id, "iteration": iteration}
+ size = 1000 if index == 0 else 1
+ summary = {
+ "execution_id": execution_id, "node_id": node_id,
+ "completed_iteration": iteration, "next_iteration": iteration + 1,
+ "batch_number": iteration // size, "batch_size": size, "batch_usage": iteration % size + 1,
+ "completed_count": iteration + 1, "exhaustion_count": iteration // size,
+ "continuation_count": iteration // size, "state": "completed", "partial": False,
+ }
+ payload = {
+ "repeat_execution_id": execution_id, "repeat": summary, "source_snapshot_changed": False,
+ "iterations": [{
+ "iteration": iteration, "iteration_path": [*copy.deepcopy(path), frame],
+ "batch_number": summary["batch_number"], "batch_size": size, "batch_usage": summary["batch_usage"],
+ "state": "completed", "condition_result": True, "partial": False,
+ "before_available": True, "after_available": True, "execution_ids": [],
+ }],
+ "next_cursor": None, "total_count": iteration + 1,
+ }
+ else:
+ frame = {"loop_id": node_id, "item_id": canonical_digest({"loop": node_id, "path": path}), "index": 0}
+ payload = {
+ "loop_execution_id": execution_id, "frozen_at": "2026-09-19T12:00:00Z",
+ "items": [{
+ "item_id": frame["item_id"], "index": frame["index"], "label": "Exact frozen finding",
+ "state": "completed", "iteration_path": [*copy.deepcopy(path), frame],
+ "execution_ids": [], "record_count": 2,
+ }],
+ "next_cursor": "more-frozen-items", "total_count": 5000, "limit": 5000,
+ }
+ self.loop_pages[(*key, execution_id)] = payload
+ path.append(frame)
+ self.mixed_frames.append(copy.deepcopy(frame))
+ rows = [
+ {"finding": "Frozen exact row", "zero": 0, "flag": False, "nested": {"nil": None}},
+ {"finding": "Frozen exact row", "zero": 0, "flag": False, "nested": {"nil": None}},
+ ]
+ self._add_execution(key, "inspect-record", path, attempt=2, records=rows)
+ self.execution_pages[key] = {"": {"items": [outer], "next_cursor": "not-automatically-loaded"}}
+
+ def hold_next_flow(self, *, source_kind=None, node_id=None, group_id=None):
+ self.flow_response_gates.append({"source_kind": source_kind, "node_id": node_id, "group_id": group_id})
+
+ def hold_next_read(self, path, *, query=None):
+ assert re.match(r"/api/(user|group)/workflows/", path), path
+ self.flow_response_gates.append({"path": path, "query": copy.deepcopy(query or {})})
+
+ def release_flow_responses(self):
+ held, self.held_flow_responses = self.held_flow_responses, []
+ for route, payload, status in held:
+ super()._json(route, payload, status)
+
+ def _json(self, route, payload, status=200):
+ if isinstance(payload, dict) and payload.get("projection_version") == 1:
+ self.flow_payloads.append(copy.deepcopy(payload))
+ for index, gate in enumerate(self.flow_response_gates):
+ if "path" in gate:
+ url = urlsplit(route.request.url)
+ query = parse_qs(url.query)
+ matches = route.request.method == "GET" and url.path == gate["path"] and all(
+ query.get(name) == value for name, value in gate["query"].items()
+ )
+ else:
+ source = payload.get("source") if isinstance(payload, dict) else None
+ matches = isinstance(source, dict) and (
+ (gate["source_kind"] is None or gate["source_kind"] == source["kind"])
+ and (gate["node_id"] is None or gate["node_id"] == payload.get("node_id"))
+ and (gate["group_id"] is None or gate["group_id"] == source["scope_id"])
+ )
+ if matches:
+ self.flow_response_gates.pop(index)
+ self.held_flow_responses.append((route, copy.deepcopy(payload), status))
+ return
+ super()._json(route, payload, status)
+
+ def _flow_resource(self, route, entry):
+ scope = entry.path.split("/")[2]
+ group_id = entry.query.get("group_id", [None])[0]
+ assert group_id in self.group_workflows if scope == "group" else group_id is None, entry
+ selectors = {
+ name: entry.body[name] if entry.method == "POST" else entry.query[name][0]
+ for name in ("node_id", "section", "revision", "cursor", "limit")
+ if name in (entry.body if entry.method == "POST" else entry.query)
+ }
+ if "node_id" in selectors:
+ if entry.method == "POST":
+ assert type(selectors.get("limit")) is int and selectors["limit"] == 50, entry
+ else:
+ assert selectors.get("limit") == "50", entry
+ assert "revision" in selectors and "section" in selectors, entry
+ if "limit" in selectors:
+ selectors["limit"] = int(selectors["limit"])
+ try:
+ if entry.method == "POST":
+ assert entry.path.endswith("/flow-preview"), entry
+ assert set(entry.body) <= {"definition", "node_id", "section", "revision", "cursor", "limit"}, entry
+ payload = self.inspection.preview_workflow_flow(
+ entry.body["definition"], user_id=OWNER_ID, group_id=group_id, **selectors,
+ )
+ else:
+ parts = entry.path.split("/")
+ workflow_id = parts[4]
+ if len(parts) == 8:
+ key = (scope, workflow_id, parts[6])
+ assert key in self.flow_definitions, f"No frozen fixture definition: {entry}"
+ payload = self.inspection.workflow_flow_inspection(
+ self.flow_definitions[key], source_kind="run", run_id=key[2],
+ snapshot_sha256=canonical_digest(self.flow_definitions[key]), **selectors,
+ )
+ else:
+ workflows = self.group_workflows[group_id] if group_id else self.personal_workflows
+ assert workflow_id in workflows, entry
+ payload = self.inspection.workflow_flow_inspection(workflows[workflow_id], **selectors)
+ self._json(route, payload)
+ except WorkflowDefinitionConflict as error:
+ self._json(route, {"error": error.public_message, "code": "workflow_flow_revision_changed"}, 409)
+ except WorkflowDefinitionError as error:
+ self._json(route, {"error": error.public_message, "code": "invalid_workflow_definition"}, 400)
+
+ def _flow_execution_resource(self, route, entry):
+ parts = entry.path.split("/")
+ key = self._key(entry)
+ if len(parts) == 8:
+ assert set(entry.query) <= {"group_id", "node_id", "iteration_path", "limit"}, entry
+ assert entry.query.get("limit") == ["1"] and "iteration_path" in entry.query, entry
+ node_id = entry.query["node_id"][0]
+ path = json.loads(entry.query["iteration_path"][0])
+ execution_id = self.execution_for(key[1], key[2], node_id, path, scope=key[0])
+ saved = self.exact_executions.get((*key, execution_id))
+ self._json(route, {"executions": [saved] if saved else [], "next_cursor": None, "total_count": int(saved is not None)})
+ return
+ execution_id, resource = parts[8], parts[-1]
+ assert (*key, execution_id) in self.exact_executions, entry
+ if resource == "attempts":
+ assert entry.query.get("limit") == ["50"] and "cursor" not in entry.query, entry
+ attempts = self.flow_attempts[(*key, execution_id)]
+ self._json(route, {"attempts": attempts, "next_cursor": None, "total_count": len(attempts)})
+ elif resource in {"items", "iterations"}:
+ assert entry.query.get("limit") == ["50"] and "cursor" not in entry.query, entry
+ self._json(route, copy.deepcopy(self.loop_pages[(*key, execution_id)]))
+ elif resource == "state":
+ assert entry.query.get("limit") == ["50"] and "cursor" not in entry.query, entry
+ phase = entry.query.get("phase", [None])[0]
+ iteration = int(parts[10])
+ assert phase in {"before", "after"}, entry
+ rounds = self.loop_pages[(*key, execution_id)]["iterations"]
+ assert any(item["iteration"] == iteration for item in rounds), entry
+ self._json(route, {
+ "repeat_execution_id": execution_id, "iteration": iteration, "phase": phase,
+ "available": True, "partial": False, "source_snapshot_changed": False,
+ "states": [{
+ "name": "review", "kind": "json",
+ "source": {
+ "node_id": "seed-decision",
+ "execution_id": self.execution_for(key[1], key[2], "seed-decision", [], scope=key[0]),
+ "iteration_path": [], "attempt": 1, "output_name": "json",
+ },
+ "workflow_validation": {"version": 1, "status": "valid", "eligible": True, "reason_codes": [], "counts": {}},
+ "coverage": {}, "limitations": [],
+ }],
+ "total_count": 1, "next_cursor": None,
+ })
+ elif resource in {"records", "result"}:
+ attempt = int(parts[10])
+ assert (*key, execution_id, attempt) in self.flow_records, entry
+ assert entry.query.get("output") in (["records"], ["authoritative"]), entry
+ rows = self.flow_records[(*key, execution_id, attempt)]
+ if resource == "records":
+ assert entry.query.get("limit") == ["100"] and "cursor" not in entry.query, entry
+ self._json(route, {
+ "records": rows, "record_offset": 0, "total_count": len(rows), "next_cursor": None,
+ "output_name": entry.query["output"][0],
+ "workflow_validation": {"version": 1, "status": "valid", "eligible": True, "reason_codes": [], "counts": {}},
+ "coverage": {"complete": True, "record_count": len(rows)},
+ })
+ else:
+ assert entry.query.get("limit") == ["2000"], entry
+ content = json.dumps({"kind": "records", "value": rows}, ensure_ascii=True, separators=(",", ":"))
+ self._json(route, {
+ "content": content, "offset": 0, "next_offset": None, "total_bytes": len(content),
+ "output_name": "records", "complete": True, "sha256": hashlib.sha256(content.encode("ascii")).hexdigest(),
+ })
+ else:
+ raise AssertionError(f"Unexpected eager Flow evidence read: {entry}")
+
+ def _dispatch(self, route, entry):
+ if entry.method != "GET" and not (entry.method == "POST" and entry.path.endswith("/flow-preview")):
+ self.unexpected_requests.append(f"Read-only Flow issued a mutation: {entry}")
+ self._json(route, {"error": "Read-only fixture rejects workflow and preference mutations."}, 405)
+ elif re.fullmatch(r"/api/(user|group)/workflows(?:/flow-preview|/[^/]+(?:/runs/[^/]+)?/flow)", entry.path):
+ self._flow_resource(route, entry)
+ elif (
+ re.fullmatch(r"/api/(user|group)/workflows/[^/]+/runs/[^/]+/executions(?:/.*)?", entry.path)
+ and (entry.query.get("node_id") or len(entry.path.split("/")) > 8)
+ and tuple(entry.path.split("/")[index] for index in (2, 4, 6)) in self.flow_definitions
+ ):
+ self._flow_execution_resource(route, entry)
+ elif entry.path == "/api/groups":
+ self._json(route, {
+ "groups": [
+ {"id": group_id, "name": name, "userRole": "Admin" if self.group_can_manage else "User"}
+ for group_id, name in ((GROUP_ID, "Alpha Group"), (SECOND_GROUP_ID, "Beta Group"))
+ ],
+ "page": 1, "page_size": 25, "total_count": 2,
+ })
+ elif entry.path == "/api/group/workflows":
+ group_id = entry.query.get("group_id", [None])[0]
+ assert group_id in self.group_workflows, entry
+ self._json(route, {"workflows": list(self.group_workflows[group_id].values())})
+ elif entry.path == f"/api/group/workflows/{FLOW_WORKFLOW_ID}/runs":
+ assert entry.query.get("group_id") == [GROUP_ID], entry
+ frozen = self.flow_definitions[("group", FLOW_WORKFLOW_ID, FLOW_RUN_ID)]
+ self._json(route, {"runs": [{
+ "id": FLOW_RUN_ID, "workflow_id": FLOW_WORKFLOW_ID, "definition_version": 3,
+ "definition_revision": frozen["definition_revision"], "durable_execution": True,
+ "status": "completed", "started_at": "2026-09-19T12:00:00Z",
+ }]})
+ elif entry.path in {"/api/group/agents", "/api/group/plugins", "/api/plugins/agent-targets"}:
+ assert entry.query.get("group_id", [None])[0] in self.group_workflows, entry
+ self._json(route, {
+ "agents": [], "actions": [], "targets": [], "can_manage": self.group_can_manage,
+ "scope_type": "group", "scope_id": entry.query["group_id"][0],
+ })
+ else:
+ super()._dispatch(route, entry)
+
+ def assert_read_only(self):
+ assert not self.workflow_writes
+ assert all(entry.method == "POST" and entry.path.endswith("/flow-preview") for entry in self.writes), self.writes
+ assert all(entry.path.endswith("/flow") for entry in self.detail_requests)
+
+ def assert_clean(self):
+ assert not self.held_flow_responses, "A test left an explicit Flow response gate closed."
+ assert not self.flow_response_gates, "A deferred Flow request was never made."
+ self.assert_read_only()
+ super().assert_clean()
+
+
+@pytest.fixture(scope="session")
+def connect_options():
+ assert not os.getenv("PLAYWRIGHT_SERVICE_URL"), "M5A offline tests require PLAYWRIGHT_SERVICE_URL=''."
+ return {}
+
+
+@pytest.fixture
+def workflow_flow_ui(page):
+ fixture = WorkflowFlowFixture(page)
+ yield fixture
+ try:
+ fixture.assert_clean()
+ finally:
+ fixture.release_flow_responses()
+ fixture.flow_response_gates.clear()
diff --git a/ui_tests/test_v2_workflow_flow_inspection.py b/ui_tests/test_v2_workflow_flow_inspection.py
new file mode 100644
index 000000000..75029c2d7
--- /dev/null
+++ b/ui_tests/test_v2_workflow_flow_inspection.py
@@ -0,0 +1,1106 @@
+# test_v2_workflow_flow_inspection.py
+"""
+Offline browser regressions for approved M5A read-only workflow Flow inspection.
+Version: 0.261.121
+Implemented in: 0.261.121
+
+Uses the real local SPA, compiler-derived projections and closed fictional APIs.
+Run with PLAYWRIGHT_SERVICE_URL='' in this same pytest process. No live app,
+Azure browser, workflow save/run, runtime decision or publication is permitted.
+"""
+
+import copy
+import json
+import re
+import sys
+from pathlib import Path
+
+import pytest
+from playwright.sync_api import expect
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / "ui_tests"))
+sys.path.insert(0, str(ROOT / "ui_tests" / "fixtures"))
+
+# Shared fixtures require their existing local import-path setup.
+from ui_tests.fixtures.workflow_flow import (
+ FLOW_NAME,
+ FLOW_RUN_ID,
+ FLOW_WORKFLOW_ID,
+ GROUP_ID,
+ MALICIOUS_LABEL,
+ MIXED_NAME,
+ MIXED_RUN_ID,
+ MIXED_WORKFLOW_ID,
+ SECOND_GROUP_ID,
+ WORKFLOW_ID,
+ connect_options, # noqa: F401
+ flow_binding,
+ workflow_flow_ui, # noqa: F401
+)
+from functions_workflow_definitions import workflow_definition_revision
+from functions_workflow_identity import workflow_execution_id
+
+
+pytestmark = pytest.mark.ui
+
+
+def flow_region(page):
+ return page.get_by_role("region", name="Workflow Flow", exact=True)
+
+
+def node_button(view, node_id):
+ return view.locator(f"button[data-workflow-node-id='{node_id}']")
+
+
+def select_node(view, node_id):
+ node = node_button(view, node_id)
+ node.focus()
+ node.press("Enter")
+ expect(node).to_have_attribute("aria-pressed", "true")
+ inspector = view.get_by_role("region", name="Flow node inspection", exact=True)
+ expect(inspector).to_be_visible()
+ expect(inspector.get_by_role("button", name="Refresh node details", exact=True)).to_be_enabled()
+ return inspector
+
+
+def open_saved(ui, name=FLOW_NAME, *, group_id=None, **options):
+ ui.open("/groups" if group_id else "/workspace/workflows", **options)
+ if group_id:
+ ui.page.get_by_label("Group workspace", exact=True).select_option(group_id)
+ ui.page.get_by_role("button", name=f"View Flow for {name}", exact=True).click()
+ expect(ui.page.get_by_role("dialog", name="Workflow Flow", exact=True)).to_be_visible()
+ view = flow_region(ui.page)
+ expect(view.get_by_role("heading", name="Read-only Flow", exact=True)).to_be_visible()
+ expect(view.get_by_role("button", name="Refresh Flow", exact=True)).to_be_enabled()
+ return view
+
+
+def open_editor(ui, workflow_id=FLOW_WORKFLOW_ID, **options):
+ ui.open(f"/workspace/workflows?workflow_id={workflow_id}", **options)
+ editor = ui.page.get_by_role("dialog", name="Edit workflow", exact=True)
+ expect(editor).to_be_visible()
+ expect(editor.get_by_role("button", name="Show Flow preview", exact=True)).to_be_visible()
+ assert not ui.preview_requests, "Flow preview must remain opt-in for existing List users."
+ return editor
+
+
+def show_preview(editor):
+ editor.get_by_role("button", name="Show Flow preview", exact=True).click()
+ view = editor.get_by_role("region", name="Workflow Flow", exact=True)
+ expect(view.get_by_text("Unsaved draft", exact=True)).to_be_visible()
+ expect(view.get_by_role("button", name="Refresh Flow", exact=True)).to_be_enabled()
+ expect(node_button(view, "root")).to_be_visible()
+ return view
+
+
+def open_run_flow(ui, *, workflow_name=FLOW_NAME, workflow_id=FLOW_WORKFLOW_ID, run_id=FLOW_RUN_ID, group=False):
+ ui.open("/groups" if group else "/workspace/workflows")
+ if group:
+ ui.page.get_by_label("Group workspace", exact=True).select_option(GROUP_ID)
+ row = ui.page.get_by_role("listitem").filter(has=ui.page.get_by_role(
+ "button", name=f"View Flow for {workflow_name}", exact=True,
+ )).first
+ row.get_by_role("button", name="Show run history", exact=True).click()
+ row.get_by_role("button", name="Show run task results", exact=True).click()
+ expect(row.get_by_role("list", name="Workflow node executions", exact=True)).to_be_visible()
+ before = len([entry for entry in ui.requests if entry.path.endswith("/executions")])
+ row.get_by_role("button", name="Show Flow for this run", exact=True).click()
+ view = flow_region(ui.page)
+ expect(view.get_by_text("Run's frozen definition", exact=True)).to_be_visible()
+ expect(view.get_by_role("button", name="Refresh Flow", exact=True)).to_be_enabled()
+ expect(view.get_by_text("Loading bounded execution overlay...", exact=True)).to_have_count(0)
+ expect(row.get_by_role("list", name="Workflow node executions", exact=True)).to_have_count(0)
+ pages = [entry for entry in ui.requests if entry.path.endswith("/executions")][before:]
+ assert len(pages) == 1 and pages[0].query == {
+ "limit": ["50"], **({"group_id": [GROUP_ID]} if group else {}),
+ }, pages
+ assert pages[0].path == f"/api/{'group' if group else 'user'}/workflows/{workflow_id}/runs/{run_id}/executions"
+ return view
+
+
+def close_saved(page):
+ page.get_by_role("dialog", name="Workflow Flow", exact=True).get_by_role("button", name="Close", exact=True).click()
+ expect(page.get_by_role("dialog", name="Workflow Flow", exact=True)).to_have_count(0)
+
+
+def assert_no_eager_content(ui):
+ assert not ui.evidence_requests, ui.evidence_requests
+ assert not ui.detail_requests, ui.detail_requests
+ assert not ui.exact_requests, ui.exact_requests
+
+
+def test_saved_topology_uses_canonical_control_boundaries_and_lazy_typed_bindings(workflow_flow_ui):
+ ui = workflow_flow_ui
+ view = open_saved(ui)
+ expect(view.get_by_text("Saved definition", exact=True)).to_be_visible()
+ expected = ui.inspection.workflow_flow_inspection(ui.personal_workflows[FLOW_WORKFLOW_ID])
+ expect(view.locator("[data-workflow-node-id]")).to_have_count(len(expected["nodes"]))
+ assert_no_eager_content(ui)
+ assert len(ui.topology_requests) == 1
+ topology = next(payload for payload in ui.flow_payloads if "nodes" in payload)
+ assert all(field not in json.dumps(topology) for field in ("LIVE_ONLY_INSTRUCTIONS", "output_contract", "selected_agent"))
+ for node_id, name in (
+ ("root", "Select Workflow (Region)"), ("choose", "Select If (If / else)"),
+ ("decision-join", "Select Join (Join)"), ("bypass-note", "Select Route (Forward route)"),
+ ("accepted-path", "Select Then (Region)"), ("review-path", "Select Else (Region)"),
+ ):
+ expect(node_button(view, node_id)).to_have_accessible_name(name)
+ expect(view).to_contain_text("Solid arrows show control flow")
+ expect(view).to_contain_text("Dashed arrows show only the selected page of typed bindings")
+ expect(view.locator(".workflow-flow-data-edge")).to_have_count(0)
+
+ inspector = select_node(view, "bypass-note")
+ inspector.get_by_text(re.compile(r"^Control-flow relationships \(")).click()
+ relationships = inspector.get_by_role("list", name="Control-flow relationships", exact=True)
+ expect(relationships).to_contain_text("True: route forward")
+ expect(relationships).to_contain_text("False: Next")
+ inspector = select_node(view, "note")
+ inspector.get_by_label("Inspection section", exact=True).select_option("condition")
+ expect(inspector).to_contain_text("decision.add_note equals true")
+ inspector = select_node(view, "finish")
+ inspector.get_by_label("Inspection section", exact=True).select_option("inputs")
+ expect(inspector).to_contain_text("Showing 2 of 2 typed inputs entries")
+ expect(inspector).to_contain_text("report: text. Required.")
+ expect(inspector).to_contain_text("note: text. Optional.")
+ expect(view.locator(".workflow-flow-data-edge")).to_have_count(2)
+ assert view.locator(".workflow-flow-data-edge path").first.evaluate(
+ "element => getComputedStyle(element).strokeDasharray"
+ ) != "none"
+ assert ui.detail_requests[-1].query == {
+ "node_id": ["finish"], "section": ["inputs"],
+ "revision": [topology["source"]["definition_revision"]], "limit": ["50"],
+ }
+ inspector.get_by_role("button", name="Inspect producer decision-join", exact=True).click()
+ expect(node_button(view, "decision-join")).to_have_attribute("aria-pressed", "true")
+ inspector = select_node(view, "root")
+ inspector.get_by_label("Inspection section", exact=True).select_option("outputs")
+ expect(inspector.get_by_role("button", name="Inspect producer finish", exact=True)).to_be_visible()
+ assert not ui.evidence_requests
+
+
+def test_nested_condition_summary_preserves_grouping_and_exact_normalized_data(workflow_flow_ui):
+ ui = workflow_flow_ui
+ definition = ui.personal_workflows[FLOW_WORKFLOW_ID]
+ branch = next(node for node in definition["flow"]["nodes"] if node["id"] == "choose")
+ branch["condition"] = {
+ "op": "all", "conditions": [
+ {"op": "any", "conditions": [
+ {"op": "eq", "left": {"input": "decision", "path": "/pass"}, "right": {"literal": True}},
+ {"op": "eq", "left": {"input": "decision", "path": "/add_note"}, "right": {"literal": True}},
+ ]},
+ {"op": "not", "condition": {
+ "op": "eq", "left": {"input": "decision", "path": "/pass"}, "right": {"literal": False},
+ }},
+ ],
+ }
+ definition["definition_revision"] = workflow_definition_revision(definition)
+ original = copy.deepcopy(definition)
+ view = open_saved(ui)
+ inspector = select_node(view, "choose")
+ inspector.get_by_label("Inspection section", exact=True).select_option("condition")
+ expect(inspector).to_contain_text(
+ "(decision.pass equals true OR decision.add_note equals true) AND NOT (decision.pass equals false)"
+ )
+ disclosure = inspector.locator("details").filter(
+ has=ui.page.get_by_text("Exact normalized condition", exact=True),
+ )
+ expect(disclosure.locator("pre")).to_be_hidden()
+ disclosure.get_by_text("Exact normalized condition", exact=True).click()
+ expect(disclosure.locator("pre")).to_be_visible()
+ expected = ui.inspection.workflow_flow_inspection(
+ definition, node_id="choose", section="condition", revision=definition["definition_revision"], limit=50,
+ )["items"][0]["value"]
+ assert json.loads(disclosure.locator("pre").inner_text()) == expected
+ assert ui.personal_workflows[FLOW_WORKFLOW_ID] == original
+ assert not ui.exact_requests and not ui.evidence_requests
+
+
+def test_v1_v2_have_no_implicit_flow_conversion_or_preview_request(workflow_flow_ui):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ original = copy.deepcopy({key: ui.personal_workflows[key] for key in (WORKFLOW_ID, ui.legacy_workflow["id"])})
+ ui.open("/workspace/workflows")
+ for name in ("Quarterly review workflow", "Legacy version one"):
+ expect(page.get_by_role("button", name=f"View Flow for {name}", exact=True)).to_have_count(0)
+ page.get_by_role("button", name="Edit Quarterly review workflow", exact=True).click()
+ expect(page.get_by_role("button", name="Show Flow preview", exact=True)).to_have_count(0)
+ expect(page.get_by_role("button", name="Enable structured control flow", exact=True)).to_be_visible()
+ page.get_by_role("dialog", name="Edit workflow", exact=True).get_by_role("button", name="Close", exact=True).click()
+ expect(page.get_by_role("dialog")).to_have_count(0)
+ assert not ui.topology_requests and not ui.preview_requests
+ assert {key: ui.personal_workflows[key] for key in original} == original
+
+
+def test_saved_flow_is_available_while_editing_is_disabled_by_an_active_run(workflow_flow_ui):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ record = ui.personal_workflows[FLOW_WORKFLOW_ID]
+ record.update(active_run_id=FLOW_RUN_ID, status="running")
+ view = open_saved(ui)
+ expect(node_button(view, "evaluate")).to_be_visible()
+ expect(view.get_by_role("button", name=re.compile(r"^(Save|Run|Approve|Retry|Resume|Publish|Continue Repeat)$"))).to_have_count(0)
+ close_saved(page)
+ expect(page.get_by_role(
+ "button", name=f"{FLOW_NAME} is running; cancel or wait before editing", exact=True,
+ )).to_be_disabled()
+ ui.assert_read_only()
+
+
+@pytest.mark.parametrize(("width", "node_id"), [(1440, "root"), (390, "evaluate")])
+def test_non_draggable_canvas_nodes_remain_pointer_selectable(workflow_flow_ui, width, node_id):
+ ui = workflow_flow_ui
+ view = open_saved(ui, width=width, height=844)
+ if width < 640:
+ view.get_by_role("button", name="Flow diagram", exact=True).click()
+ node = node_button(view, node_id)
+ node.click(timeout=5000)
+ expect(node).to_have_attribute("aria-pressed", "true")
+ expect(view.get_by_role("region", name="Flow node inspection", exact=True)).to_be_visible()
+ assert not ui.exact_requests and not ui.evidence_requests
+
+
+def test_constructor_id_has_finite_resettable_temporary_positions(workflow_flow_ui):
+ ui = workflow_flow_ui
+ definition = ui.personal_workflows[FLOW_WORKFLOW_ID]
+ definition["flow"]["nodes"][0]["id"] = "constructor"
+ for owner in [*definition["flow"]["nodes"], *definition["tasks"]]:
+ for binding in owner.get("inputs", []):
+ if binding["source"].get("node_id") == "evaluate":
+ binding["source"]["node_id"] = "constructor"
+ definition["definition_revision"] = workflow_definition_revision(definition)
+ original = copy.deepcopy(definition)
+ view = open_saved(ui)
+ expect(node_button(view, "constructor")).to_have_accessible_name("Select Later live evaluation (Task)")
+ select_node(view, "constructor")
+ assert ui.detail_requests[-1].query["node_id"] == ["constructor"]
+ wrapper = view.locator(".react-flow__node[data-id='constructor']")
+ assert wrapper.evaluate("""element => {
+ const matrix = new DOMMatrixReadOnly(getComputedStyle(element).transform);
+ return Number.isFinite(matrix.m41) && Number.isFinite(matrix.m42);
+ }""")
+ original_transform = wrapper.evaluate("element => getComputedStyle(element).transform")
+ view.get_by_role("button", name="Move box right", exact=True).click()
+ expect(wrapper).not_to_have_css("transform", original_transform)
+ view.get_by_role("button", name="Reset layout", exact=True).click()
+ expect(wrapper).to_have_css("transform", original_transform)
+ expect(view.get_by_role("alert")).to_have_count(0)
+ assert ui.personal_workflows[FLOW_WORKFLOW_ID] == original
+
+
+@pytest.mark.browser_context_args(has_touch=True)
+def test_coarse_pointer_diagram_uses_explicit_pan_without_capturing_browser_gestures(workflow_flow_ui):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ view = open_saved(ui, width=1024, height=900)
+ assert page.evaluate("matchMedia('(pointer: coarse)').matches")
+ canvas = view.locator(".workflow-flow-canvas")
+ expect(canvas).to_be_visible()
+ expect(view.locator(".react-flow__node.draggable")).to_have_count(0)
+ expect(view.locator(".react-flow__pane")).not_to_have_class(re.compile(r"\bdraggable\b"))
+ for element in (canvas, view.locator(".react-flow__pane")):
+ action = element.evaluate("element => getComputedStyle(element).touchAction")
+ assert action == "manipulation" or {"pan-x", "pan-y", "pinch-zoom"} <= set(action.split())
+ viewport = view.locator(".react-flow__viewport")
+ original = viewport.evaluate("element => getComputedStyle(element).transform")
+ view.get_by_role("button", name="Pan view left", exact=True).click()
+ expect(viewport).not_to_have_css("transform", original)
+ view.get_by_role("button", name="Pan view right", exact=True).click()
+ expect(viewport).to_have_css("transform", original)
+ view.get_by_role("button", name="Pan view up", exact=True).click()
+ expect(viewport).not_to_have_css("transform", original)
+ view.get_by_role("button", name="Pan view down", exact=True).click()
+ expect(viewport).to_have_css("transform", original)
+ assert_no_eager_content(ui)
+ ui.assert_no_overflow()
+
+
+def test_layout_collapse_and_keyboard_focus_do_not_change_definition_revision_or_storage(workflow_flow_ui):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ original = copy.deepcopy(ui.personal_workflows[MIXED_WORKFLOW_ID])
+ view = open_saved(ui, MIXED_NAME)
+ storage = page.evaluate("JSON.stringify({local: {...localStorage}, session: {...sessionStorage}})")
+ expect(node_button(view, "loop-0")).to_be_visible()
+ expect(node_button(view, "loop-1")).to_have_count(0)
+ view.get_by_role("button", name="Expand Repeat until", exact=True).click()
+ expect(node_button(view, "loop-1")).to_have_count(1)
+ select_node(view, "loop-1")
+ for name in ("Zoom in", "Zoom out", "Fit Flow", "Move box right", "Move box down", "Reset layout"):
+ view.get_by_role("button", name=name, exact=True).click()
+ node_button(view, "loop-1").focus()
+ node_button(view, "loop-1").press("Delete")
+ expect(node_button(view, "loop-1")).to_have_count(1)
+ view.get_by_role("button", name="Collapse Repeat until", exact=True).click()
+ expect(node_button(view, "loop-1")).to_have_count(0)
+ expect(node_button(view, "loop-0")).to_have_attribute("aria-pressed", "true")
+ expect(node_button(view, "loop-0")).to_be_focused()
+ assert page.evaluate("JSON.stringify({local: {...localStorage}, session: {...sessionStorage}})") == storage
+ assert ui.personal_workflows[MIXED_WORKFLOW_ID] == original
+ assert workflow_definition_revision(ui.personal_workflows[MIXED_WORKFLOW_ID]) == original["definition_revision"]
+ close_saved(page)
+ expect(page.get_by_role("dialog")).to_have_count(0)
+
+
+def test_list_preview_updates_without_save_or_cas_changes_and_uses_desktop_columns(workflow_flow_ui):
+ ui = workflow_flow_ui
+ original = copy.deepcopy(ui.personal_workflows[FLOW_WORKFLOW_ID])
+ editor = open_editor(ui)
+ view = show_preview(editor)
+ expect(node_button(view, "evaluate")).to_have_accessible_name("Select Later live evaluation (Task)")
+ select_node(view, "evaluate")
+ wrapper = view.locator(".react-flow__node[data-id='evaluate']")
+ original_transform = wrapper.evaluate("element => getComputedStyle(element).transform")
+ requests_before_move = len(ui.preview_requests)
+ view.get_by_role("button", name="Move box right", exact=True).click()
+ expect(wrapper).not_to_have_css("transform", original_transform)
+ moved_transform = wrapper.evaluate("element => getComputedStyle(element).transform")
+ assert len(ui.preview_requests) == requests_before_move
+ task = editor.get_by_role("region", name="Later live evaluation block", exact=True)
+ task.get_by_label("Task name", exact=True).fill("Edited only in List")
+ expect(node_button(view, "evaluate")).to_have_accessible_name("Select Edited only in List (Task)")
+ expect(wrapper).to_have_css("transform", moved_transform)
+ assert ui.preview_requests[-1].body["definition"]["definition_revision"] == original["definition_revision"]
+ assert ui.personal_workflows[FLOW_WORKFLOW_ID] == original
+ assert not ui.workflow_writes
+ list_box = editor.get_by_role("group", name="Main region", exact=True).bounding_box()
+ flow_box = view.bounding_box()
+ assert list_box and flow_box and list_box["x"] + list_box["width"] <= flow_box["x"] + 1
+ expect(editor.get_by_role("button", name="Hide Flow preview", exact=True)).to_be_visible()
+
+
+def test_opening_and_layout_of_unchanged_preview_does_not_make_editor_dirty(workflow_flow_ui):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ original = copy.deepcopy(ui.personal_workflows[FLOW_WORKFLOW_ID])
+ editor = open_editor(ui)
+ view = show_preview(editor)
+ for name in ("Zoom in", "Zoom out", "Fit Flow", "Reset layout"):
+ view.get_by_role("button", name=name, exact=True).click()
+ editor.get_by_role("button", name="Hide Flow preview", exact=True).click()
+ expect(flow_region(page)).to_have_count(0)
+ editor.get_by_role("button", name="Close", exact=True).click()
+ expect(page.get_by_role("dialog")).to_have_count(0)
+ assert ui.personal_workflows[FLOW_WORKFLOW_ID] == original
+
+
+def test_invalid_list_draft_retains_edits_but_removes_last_valid_graph(workflow_flow_ui):
+ ui = workflow_flow_ui
+ original = copy.deepcopy(ui.personal_workflows[FLOW_WORKFLOW_ID])
+ editor = open_editor(ui)
+ view = show_preview(editor)
+ expect(node_button(view, "evaluate")).to_have_count(1)
+ instructions = editor.get_by_role("region", name="Later live evaluation block", exact=True).get_by_label("Instructions", exact=True)
+ instructions.fill("")
+ expect(view.get_by_role("alert")).to_contain_text("Task instructions must be nonempty")
+ expect(view.locator("[data-workflow-node-id]")).to_have_count(0)
+ expect(instructions).to_have_value("")
+ assert ui.personal_workflows[FLOW_WORKFLOW_ID] == original
+ instructions.fill("A repaired but still unsaved instruction.")
+ expect(node_button(view, "evaluate")).to_have_count(1)
+ expect(view.get_by_role("alert")).to_have_count(0)
+ assert ui.preview_requests[-1].body["definition"]["definition_revision"] == original["definition_revision"]
+
+
+def test_delayed_draft_projection_cannot_replace_a_newer_list_edit(workflow_flow_ui):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ editor = open_editor(ui)
+ view = show_preview(editor)
+ ui.hold_next_flow(source_kind="draft")
+ with page.expect_request(lambda request: request.url.endswith("/flow-preview")):
+ editor.get_by_role("region", name="Later live evaluation block", exact=True).get_by_label("Task name", exact=True).fill("Older queued draft")
+ expect(view.get_by_text("Checking the current List draft...", exact=True)).to_be_visible()
+ editor.get_by_role("region", name="Older queued draft block", exact=True).get_by_label("Task name", exact=True).fill("Newest retained draft")
+ expect(node_button(view, "evaluate")).to_have_accessible_name("Select Newest retained draft (Task)")
+ assert len(ui.held_flow_responses) == 1
+ ui.release_flow_responses()
+ page.wait_for_load_state("networkidle")
+ expect(node_button(view, "evaluate")).to_have_accessible_name("Select Newest retained draft (Task)")
+ expect(view.get_by_text("Older queued draft", exact=True)).to_have_count(0)
+
+
+def test_delayed_valid_preview_cannot_resurrect_a_graph_for_an_invalid_draft(workflow_flow_ui):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ editor = open_editor(ui)
+ view = show_preview(editor)
+ ui.hold_next_flow(source_kind="draft")
+ with page.expect_request(lambda request: request.url.endswith("/flow-preview")):
+ editor.get_by_role("region", name="Later live evaluation block", exact=True).get_by_label(
+ "Task name", exact=True,
+ ).fill("Pending valid draft")
+ expect(view.get_by_text("Checking the current List draft...", exact=True)).to_be_visible()
+ instructions = editor.get_by_role("region", name="Pending valid draft block", exact=True).get_by_label(
+ "Instructions", exact=True,
+ )
+ instructions.fill("")
+ expect(view.get_by_role("alert")).to_contain_text("Task instructions must be nonempty")
+ expect(view.locator("[data-workflow-node-id]")).to_have_count(0)
+ assert len(ui.held_flow_responses) == 1
+ ui.release_flow_responses()
+ page.wait_for_load_state("networkidle")
+ expect(view.get_by_role("alert")).to_contain_text("Task instructions must be nonempty")
+ expect(view.locator("[data-workflow-node-id]")).to_have_count(0)
+ expect(instructions).to_have_value("")
+
+
+def test_delayed_node_details_cannot_replace_a_new_selection(workflow_flow_ui):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ view = open_saved(ui)
+ ui.hold_next_flow(source_kind="saved", node_id="evaluate")
+ node_button(view, "evaluate").focus()
+ node_button(view, "evaluate").press("Enter")
+ inspector = view.get_by_role("region", name="Flow node inspection", exact=True)
+ expect(inspector.get_by_text("Loading selected configuration...", exact=True)).to_be_visible()
+ inspector = select_node(view, "finish")
+ expect(inspector.get_by_role("heading", name="Finish", exact=True)).to_be_visible()
+ expect(inspector).to_contain_text("Produce the finish result.")
+ assert len(ui.held_flow_responses) == 1
+ ui.release_flow_responses()
+ page.wait_for_load_state("networkidle")
+ expect(node_button(view, "finish")).to_have_attribute("aria-pressed", "true")
+ expect(inspector).to_contain_text("Produce the finish result.")
+ expect(inspector).not_to_contain_text("LIVE_ONLY_INSTRUCTIONS")
+
+
+def test_changed_saved_revision_clears_stale_inspection_before_explicit_refresh(workflow_flow_ui):
+ ui = workflow_flow_ui
+ view = open_saved(ui)
+ inspector = select_node(view, "evaluate")
+ original_revision = ui.detail_requests[-1].query["revision"][0]
+ definition = ui.personal_workflows[FLOW_WORKFLOW_ID]
+ task = next(task for task in definition["tasks"] if task["id"] == "evaluate")
+ task.update(name="Saved replacement evaluation", instructions="NEW_SAVED_INSTRUCTIONS")
+ definition["definition_revision"] = workflow_definition_revision(definition)
+ assert definition["definition_revision"] != original_revision
+ inspector.get_by_role("button", name="Refresh node details", exact=True).click()
+ expect(view.get_by_role("alert")).to_contain_text("source changed")
+ expect(view.locator("[data-workflow-node-id]")).to_have_count(0)
+ expect(view.get_by_role("region", name="Flow node inspection", exact=True)).to_have_count(0)
+ expect(view.get_by_text("NEW_SAVED_INSTRUCTIONS", exact=True)).to_have_count(0)
+ assert ui.detail_requests[-1].query["revision"] == [original_revision]
+ view.get_by_role("button", name="Refresh Flow", exact=True).click()
+ expect(node_button(view, "evaluate")).to_have_accessible_name("Select Saved replacement evaluation (Task)")
+ expect(view).to_contain_text(definition["definition_revision"])
+ select_node(view, "evaluate")
+ assert ui.detail_requests[-1].query["revision"] == [definition["definition_revision"]]
+
+
+def test_run_uses_frozen_revision_with_reused_live_ids_and_truthful_unknown_status(workflow_flow_ui):
+ ui = workflow_flow_ui
+ view = open_run_flow(ui)
+ frozen = ui.flow_definitions[("user", FLOW_WORKFLOW_ID, FLOW_RUN_ID)]
+ expect(view).to_contain_text(frozen["definition_revision"])
+ expect(view).to_contain_text(FLOW_RUN_ID)
+ expect(node_button(view, "evaluate")).to_have_accessible_name("Select Frozen evaluation (Task)")
+ expect(node_button(view, "finish")).to_contain_text("Not loaded")
+ expect(view.get_by_text("Later live evaluation", exact=True)).to_have_count(0)
+ assert_no_eager_content(ui)
+ inspector = select_node(view, "evaluate")
+ expect(inspector).to_contain_text("FROZEN_INSTRUCTIONS")
+ expect(inspector).not_to_contain_text("LIVE_ONLY_INSTRUCTIONS")
+ exact_id = workflow_execution_id(frozen, FLOW_RUN_ID, "evaluate", [])
+ expect(inspector.get_by_role("list", name=f"Attempts for execution {exact_id}", exact=True)).to_contain_text("Attempt 2")
+ assert exact_id != workflow_execution_id(ui.personal_workflows[FLOW_WORKFLOW_ID], FLOW_RUN_ID, "evaluate", [])
+ assert ui.exact_requests[-1].query == {"node_id": ["evaluate"], "iteration_path": ["[]"], "limit": ["1"]}
+ assert ui.detail_requests[-1].query["revision"] == [frozen["definition_revision"]]
+ assert not any(entry.path.endswith(("/result", "/records", "/iterations", "/state")) for entry in ui.requests)
+ inspector = select_node(view, "finish")
+ expect(inspector).to_contain_text("No execution recorded for this node in the selected instance")
+ expect(inspector).to_contain_text("not a successful or empty result")
+ expect(inspector).to_contain_text("Produce the finish result.")
+ expect(inspector.get_by_role("list", name=re.compile("^Attempts for execution "))).to_have_count(0)
+ expect(view.get_by_role("alert")).to_have_count(0)
+ expect(node_button(view, "finish")).to_contain_text("No execution recorded")
+ expect(node_button(view, "finish")).not_to_contain_text("completed")
+ assert ui.exact_requests[-1].query == {"node_id": ["finish"], "iteration_path": ["[]"], "limit": ["1"]}
+
+
+@pytest.mark.parametrize("region_id", ["accepted-path", "review-path"], ids=["then", "else"])
+def test_frozen_branch_regions_inspect_configuration_without_execution_lookups(workflow_flow_ui, region_id):
+ ui = workflow_flow_ui
+ key = ("user", FLOW_WORKFLOW_ID, FLOW_RUN_ID)
+ with pytest.raises(AssertionError, match="no execution identity"):
+ ui._add_execution(key, region_id, [])
+ with pytest.raises(AssertionError, match="no execution identity"):
+ ui.execution_for(FLOW_WORKFLOW_ID, FLOW_RUN_ID, region_id, [])
+ view = open_run_flow(ui)
+ inspector = select_node(view, region_id)
+ expect(node_button(view, region_id)).to_contain_text("Region grouping; no separate execution")
+ expect(inspector).to_contain_text("This region groups nodes; it has no separate execution record.")
+ expect(inspector.get_by_role("button", name="Refresh selected execution", exact=True)).to_have_count(0)
+ expect(inspector.get_by_role("list", name=re.compile("^Attempts for execution "))).to_have_count(0)
+ assert not ui.exact_requests and not ui.evidence_requests
+ assert ui.detail_requests[-1].query == {
+ "node_id": [region_id], "section": ["configuration"],
+ "revision": [ui.flow_definitions[key]["definition_revision"]], "limit": ["50"],
+ }
+
+ inspector.get_by_role("button", name="Inspect enclosing control If", exact=True).click()
+ expect(node_button(view, "choose")).to_have_attribute("aria-pressed", "true")
+ expect(inspector).to_contain_text("No execution recorded for this node in the selected instance")
+ assert len(ui.exact_requests) == 1
+ assert ui.exact_requests[0].query == {"node_id": ["choose"], "iteration_path": ["[]"], "limit": ["1"]}
+ assert not ui.evidence_requests
+
+
+@pytest.mark.parametrize("group", [False, True], ids=["personal", "group"])
+def test_frozen_root_keeps_its_real_engine_execution(workflow_flow_ui, group):
+ ui = workflow_flow_ui
+ scope = "group" if group else "user"
+ key = (scope, FLOW_WORKFLOW_ID, FLOW_RUN_ID)
+ execution = ui._add_execution(key, "root", [])
+ assert execution["node_kind"] == "root" and execution["region_id"] == "root"
+ view = open_run_flow(ui, workflow_name="Alpha read-only Flow" if group else FLOW_NAME, group=group)
+ inspector = select_node(view, "root")
+ expect(inspector.get_by_role(
+ "list", name=f"Attempts for execution {execution['execution_id']}", exact=True,
+ )).to_contain_text("Attempt 1")
+ expect(node_button(view, "root")).to_contain_text("completed; attempt 1")
+ expect(inspector.get_by_role("button", name="Refresh selected execution", exact=True)).to_be_visible()
+ expect(inspector).not_to_contain_text("This region groups nodes")
+ assert len(ui.exact_requests) == 1
+ assert ui.exact_requests[0].query == {
+ "node_id": ["root"], "iteration_path": ["[]"], "limit": ["1"],
+ **({"group_id": [GROUP_ID]} if group else {}),
+ }
+ assert [entry.path for entry in ui.evidence_requests] == [
+ f"/api/{scope}/workflows/{FLOW_WORKFLOW_ID}/runs/{FLOW_RUN_ID}"
+ f"/executions/{execution['execution_id']}/attempts",
+ ]
+
+
+def test_execution_overlay_pages_replace_old_evidence_without_auto_draining(workflow_flow_ui):
+ ui = workflow_flow_ui
+ view = open_run_flow(ui)
+ expect(node_button(view, "evaluate")).to_contain_text("completed; attempt 2")
+ expect(node_button(view, "review")).to_contain_text("Not loaded")
+ count = len([entry for entry in ui.requests if entry.path.endswith("/executions")])
+ view.get_by_role("button", name="Next execution overlay page", exact=True).click()
+ expect(node_button(view, "review")).to_contain_text("skipped; attempt 0")
+ expect(node_button(view, "evaluate")).to_contain_text("Not loaded")
+ expect(view.get_by_role("button", name="Next execution overlay page", exact=True)).to_be_disabled()
+ pages = [entry for entry in ui.requests if entry.path.endswith("/executions")][count:]
+ assert len(pages) == 1 and pages[0].query == {
+ "limit": ["50"], "cursor": ["later-unloaded-executions"],
+ }
+ assert_no_eager_content(ui)
+ view.get_by_role("button", name="Previous execution overlay page", exact=True).click()
+ expect(node_button(view, "evaluate")).to_contain_text("completed; attempt 2")
+ expect(node_button(view, "review")).to_contain_text("Not loaded")
+ assert_no_eager_content(ui)
+
+
+def test_delayed_exact_lookup_cannot_attach_to_a_different_selected_node(workflow_flow_ui):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ view = open_run_flow(ui)
+ path = f"/api/user/workflows/{FLOW_WORKFLOW_ID}/runs/{FLOW_RUN_ID}/executions"
+ ui.hold_next_read(path, query={"node_id": ["evaluate"], "iteration_path": ["[]"]})
+ select_node(view, "evaluate")
+ expect(view.get_by_text("Reading exact execution...", exact=True)).to_be_visible()
+ inspector = select_node(view, "finish")
+ expect(inspector).to_contain_text("No execution recorded for this node")
+ assert len(ui.held_flow_responses) == 1
+ ui.release_flow_responses()
+ page.wait_for_load_state("networkidle")
+ expect(node_button(view, "finish")).to_have_attribute("aria-pressed", "true")
+ expect(inspector).to_contain_text("No execution recorded for this node")
+ expect(inspector.get_by_role("list", name=re.compile("^Attempts for execution "))).to_have_count(0)
+ assert not any(entry.path.endswith("/attempts") for entry in ui.requests)
+
+
+@pytest.mark.parametrize("status", [403, 404])
+def test_access_loss_cannot_be_reversed_by_an_older_overlay_response(workflow_flow_ui, status):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ view = open_run_flow(ui)
+ inspector = select_node(view, "evaluate")
+ path = f"/api/user/workflows/{FLOW_WORKFLOW_ID}/runs/{FLOW_RUN_ID}"
+ ui.hold_next_read(f"{path}/executions", query={"cursor": ["later-unloaded-executions"]})
+ view.get_by_role("button", name="Next execution overlay page", exact=True).click()
+ expect(view.get_by_text("Loading bounded execution overlay...", exact=True)).to_be_visible()
+ ui.reject_next("GET", f"{path}/flow", status=status, error="The fictional source is no longer readable.")
+ inspector.get_by_role("button", name="Refresh node details", exact=True).click()
+ expect(page.locator("[data-workflow-node-id]")).to_have_count(0)
+ assert len(ui.held_flow_responses) == 1
+ ui.release_flow_responses()
+ page.wait_for_load_state("networkidle")
+ expect(page.locator("[data-workflow-node-id]")).to_have_count(0)
+ expect(page.get_by_role("region", name="Flow node inspection", exact=True)).to_have_count(0)
+ expect(page.get_by_role("list", name=re.compile("^Attempts for execution "))).to_have_count(0)
+ expect(page.get_by_role("alert").filter(has_text=re.compile("access|available|readable", re.I)).first).to_be_visible()
+
+
+@pytest.mark.parametrize("kinds", [
+ ("repeat_until", "for_each", "repeat_until"),
+ ("for_each", "repeat_until", "for_each"),
+])
+@pytest.mark.parametrize("reorder_frame_keys", [False, True], ids=["original-keys", "reordered-keys"])
+def test_nested_instances_keep_exact_mixed_path_lifetime_round_and_attempt_requests(
+ workflow_flow_ui, kinds, reorder_frame_keys,
+):
+ ui = workflow_flow_ui
+ ui.seed_mixed(kinds)
+ if reorder_frame_keys:
+ for key, execution in ui.exact_executions.items():
+ if key[:3] == ("user", MIXED_WORKFLOW_ID, MIXED_RUN_ID):
+ execution["iteration_path"] = [dict(reversed(list(frame.items()))) for frame in execution["iteration_path"]]
+ view = open_run_flow(ui, workflow_name=MIXED_NAME, workflow_id=MIXED_WORKFLOW_ID, run_id=MIXED_RUN_ID)
+ assert_no_eager_content(ui)
+ assert view.locator("[data-workflow-node-id]").count() <= 5
+ for index, kind in enumerate(kinds):
+ inspector = select_node(view, f"loop-{index}")
+ frame = ui.mixed_frames[index]
+ name = f"Use round {frame['iteration'] + 1} in Flow" if kind == "repeat_until" else f"Use item {frame['index'] + 1} in Flow"
+ expect(inspector.get_by_role("button", name=name, exact=True)).to_be_visible()
+ latest = ui.exact_requests[-1]
+ assert latest.query["node_id"] == [f"loop-{index}"]
+ assert json.loads(latest.query["iteration_path"][0]) == ui.mixed_frames[:index]
+ assert latest.query["limit"] == ["1"]
+ inspector.get_by_role("button", name=name, exact=True).click()
+ inspector = select_node(view, "inspect-record")
+ execution_id = ui.execution_for(MIXED_WORKFLOW_ID, MIXED_RUN_ID, "inspect-record", ui.mixed_frames)
+ attempts = inspector.get_by_role("list", name=f"Attempts for execution {execution_id}", exact=True)
+ expect(attempts).to_contain_text("Attempt 2")
+ expect(node_button(view, "inspect-record")).to_contain_text("completed; attempt 2")
+ assert json.loads(ui.exact_requests[-1].query["iteration_path"][0]) == ui.mixed_frames
+ if reorder_frame_keys:
+ recorded = ui.exact_executions[("user", MIXED_WORKFLOW_ID, MIXED_RUN_ID, execution_id)]["iteration_path"]
+ assert recorded == ui.mixed_frames and json.dumps(recorded) != json.dumps(ui.mixed_frames)
+ assert any(frame.get("iteration") == 1000 for frame in ui.mixed_frames)
+ expect(inspector).to_contain_text("1001")
+ assert not any(entry.path.endswith(("/records", "/result", "/state")) for entry in ui.requests)
+ attempts.get_by_role("listitem").filter(has_text="Attempt 2").get_by_role("button", name="Load complete records", exact=True).click()
+ records = inspector.get_by_role("list", name="Complete saved records", exact=True)
+ expect(records.get_by_role("listitem")).to_have_count(2)
+ expect(records).to_contain_text('"zero": 0')
+ expect(records).to_contain_text('"flag": false')
+ expect(records).to_contain_text('"nil": null')
+ record_requests = [entry for entry in ui.requests if entry.path.endswith("/records")]
+ assert len(record_requests) == 1
+ assert record_requests[0].path == (
+ f"/api/user/workflows/{MIXED_WORKFLOW_ID}/runs/{MIXED_RUN_ID}"
+ f"/executions/{execution_id}/attempts/2/records"
+ )
+ assert record_requests[0].query == {"output": ["records"], "limit": ["100"]}
+ assert not any(entry.query.get("cursor") for entry in ui.evidence_requests)
+ assert view.locator("[data-workflow-node-id]").count() < 20
+ count = len(ui.exact_requests)
+ view.get_by_role("button", name="Return to root instance", exact=True).click()
+ expect(view.get_by_role("list", name="Complete saved records", exact=True)).to_have_count(0)
+ expect(view.get_by_role("list", name=re.compile("^Attempts for execution "))).to_have_count(0)
+ expect(inspector).to_contain_text("Choose an exact frozen item or Repeat round")
+ expect(node_button(view, "inspect-record")).to_contain_text("Not loaded")
+ assert len(ui.exact_requests) == count
+
+
+def test_expanded_loop_template_does_not_invent_an_instance_or_fetch_results(workflow_flow_ui):
+ ui = workflow_flow_ui
+ view = open_run_flow(ui, workflow_name=MIXED_NAME, workflow_id=MIXED_WORKFLOW_ID, run_id=MIXED_RUN_ID)
+ for node_id, label in (("loop-0", "Repeat until"), ("loop-1", "For each"), ("loop-2", "Repeat until")):
+ control = node_button(view, node_id).locator("..").locator("button[aria-expanded]")
+ expect(control).to_have_accessible_name(f"Expand {label}")
+ control.click()
+ expect(control).to_have_attribute("aria-expanded", "true")
+ inspector = select_node(view, "inspect-record")
+ expect(inspector).to_contain_text("Choose an exact frozen item or Repeat round")
+ expect(node_button(view, "inspect-record")).to_contain_text("Not loaded")
+ assert not ui.exact_requests and not ui.evidence_requests
+
+
+@pytest.mark.parametrize("kinds", [
+ ("repeat_until", "for_each", "repeat_until"),
+ ("for_each", "repeat_until", "for_each"),
+], ids=["repeat-body", "for-each-body"])
+def test_frozen_body_region_remains_configuration_only_with_a_complete_mixed_path(workflow_flow_ui, kinds):
+ ui = workflow_flow_ui
+ ui.seed_mixed(kinds)
+ key = ("user", MIXED_WORKFLOW_ID, MIXED_RUN_ID)
+ root_execution = ui._add_execution(key, "root", [])
+ with pytest.raises(AssertionError, match="no execution identity"):
+ ui._add_execution(key, "body-2", ui.mixed_frames)
+ with pytest.raises(AssertionError, match="no execution identity"):
+ ui.execution_for(MIXED_WORKFLOW_ID, MIXED_RUN_ID, "body-2", ui.mixed_frames)
+ view = open_run_flow(ui, workflow_name=MIXED_NAME, workflow_id=MIXED_WORKFLOW_ID, run_id=MIXED_RUN_ID)
+ for index, kind in enumerate(kinds):
+ inspector = select_node(view, f"loop-{index}")
+ frame = ui.mixed_frames[index]
+ label = f"Use round {frame['iteration'] + 1} in Flow" if kind == "repeat_until" else f"Use item {frame['index'] + 1} in Flow"
+ inspector.get_by_role("button", name=label, exact=True).click()
+ leaf_id = ui.execution_for(MIXED_WORKFLOW_ID, MIXED_RUN_ID, "inspect-record", ui.mixed_frames)
+ expect(view.get_by_role("list", name=f"Attempts for execution {leaf_id}", exact=True)).to_contain_text("Attempt 2")
+ ui.page.wait_for_load_state("networkidle")
+ exact_count, evidence_count = len(ui.exact_requests), len(ui.evidence_requests)
+
+ inspector = select_node(view, "body-2")
+ expect(node_button(view, "body-2")).to_contain_text("Region grouping; no separate execution")
+ expect(inspector).to_contain_text("This region groups nodes; it has no separate execution record.")
+ expect(inspector.get_by_role("button", name="Refresh selected execution", exact=True)).to_have_count(0)
+ expect(inspector.get_by_role("list", name=re.compile("^Attempts for execution "))).to_have_count(0)
+ assert len(ui.exact_requests) == exact_count and len(ui.evidence_requests) == evidence_count
+ assert ui.detail_requests[-1].query == {
+ "node_id": ["body-2"], "section": ["configuration"],
+ "revision": [ui.flow_definitions[key]["definition_revision"]], "limit": ["50"],
+ }
+
+ control = "Repeat until" if kinds[2] == "repeat_until" else "For each"
+ inspector.get_by_role("button", name=f"Inspect enclosing control {control}", exact=True).click()
+ control_id = ui.execution_for(MIXED_WORKFLOW_ID, MIXED_RUN_ID, "loop-2", ui.mixed_frames[:2])
+ expect(node_button(view, "loop-2")).to_have_attribute("aria-pressed", "true")
+ panel = "Repeat round inspection" if kinds[2] == "repeat_until" else "Frozen item inspection"
+ expect(inspector.get_by_role("region", name=panel, exact=True)).to_be_visible()
+ expect(inspector.get_by_role("button", name=label, exact=True)).to_be_visible()
+ assert len(ui.exact_requests) == exact_count + 1
+ assert ui.exact_requests[-1].query == {
+ "node_id": ["loop-2"], "iteration_path": [json.dumps(ui.mixed_frames[:2], separators=(",", ":"))], "limit": ["1"],
+ }
+ resource = "iterations" if kinds[2] == "repeat_until" else "items"
+ assert len(ui.evidence_requests) == evidence_count + 1
+ assert ui.evidence_requests[-1].path == (
+ f"/api/user/workflows/{MIXED_WORKFLOW_ID}/runs/{MIXED_RUN_ID}/executions/{control_id}/{resource}"
+ )
+ assert ui.evidence_requests[-1].query == {"limit": ["50"]}
+ assert not any(entry.query["node_id"] == ["body-2"] for entry in ui.exact_requests)
+
+ inspector = select_node(view, "root")
+ attempts = inspector.get_by_role("list", name=f"Attempts for execution {root_execution['execution_id']}", exact=True)
+ expect(attempts).to_contain_text("Attempt 1")
+ expect(node_button(view, "root")).to_contain_text("completed; attempt 1")
+ assert ui.exact_requests[-1].query == {"node_id": ["root"], "iteration_path": ["[]"], "limit": ["1"]}
+ exact_count, evidence_count = len(ui.exact_requests), len(ui.evidence_requests)
+ view.get_by_role("button", name="Return to root instance", exact=True).click()
+ expect(node_button(view, "root")).to_have_attribute("aria-pressed", "true")
+ expect(node_button(view, "root")).to_contain_text("completed; attempt 1")
+ expect(attempts).to_contain_text("Attempt 1")
+ assert len(ui.exact_requests) == exact_count and len(ui.evidence_requests) == evidence_count
+
+
+def test_repeat_state_is_read_only_and_loaded_only_after_explicit_round_selection(workflow_flow_ui):
+ ui = workflow_flow_ui
+ view = open_run_flow(ui, workflow_name=MIXED_NAME, workflow_id=MIXED_WORKFLOW_ID, run_id=MIXED_RUN_ID)
+ inspector = select_node(view, "loop-0")
+ expect(inspector.get_by_role("button", name="Use round 1001 in Flow", exact=True)).to_be_visible()
+ assert not any(entry.path.endswith("/state") for entry in ui.requests)
+ inspector.get_by_role("button", name="State after round 1001", exact=True).click()
+ state = inspector.get_by_role("region", name="State after round 1001", exact=True)
+ expect(state).to_contain_text("review")
+ state_requests = [entry for entry in ui.requests if entry.path.endswith("/state")]
+ execution_id = ui.execution_for(MIXED_WORKFLOW_ID, MIXED_RUN_ID, "loop-0", [])
+ assert len(state_requests) == 1
+ assert state_requests[0].path == (
+ f"/api/user/workflows/{MIXED_WORKFLOW_ID}/runs/{MIXED_RUN_ID}"
+ f"/executions/{execution_id}/iterations/1000/state"
+ )
+ assert state_requests[0].query == {"phase": ["after"], "limit": ["50"]}
+ assert not any(entry.path.endswith(("/records", "/result")) for entry in ui.requests)
+
+
+@pytest.mark.parametrize("status", [403, 404])
+def test_exact_lookup_access_or_admission_failure_clears_cached_evidence(workflow_flow_ui, status):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ view = open_run_flow(ui)
+ inspector = select_node(view, "evaluate")
+ execution_id = ui.execution_for(FLOW_WORKFLOW_ID, FLOW_RUN_ID, "evaluate", [])
+ expect(inspector.get_by_role(
+ "list", name=f"Attempts for execution {execution_id}", exact=True,
+ )).to_contain_text("Attempt 2")
+ ui.reject_next(
+ "GET", f"/api/user/workflows/{FLOW_WORKFLOW_ID}/runs/{FLOW_RUN_ID}/executions",
+ status=status, error="Current access or admission for this instance could not be confirmed.",
+ )
+ inspector.get_by_role("button", name="Refresh selected execution", exact=True).click()
+ expect(page.locator("[data-workflow-node-id]")).to_have_count(0)
+ expect(page.get_by_role("region", name="Flow node inspection", exact=True)).to_have_count(0)
+ expect(page.get_by_role("list", name=re.compile("^Attempts for execution "))).to_have_count(0)
+ expect(page.get_by_text(re.compile("^No execution recorded"))).to_have_count(0)
+ expect(page.get_by_role("alert").filter(has_text=re.compile("access|available|readable", re.I)).first).to_be_visible()
+ assert ui.exact_requests[-1].query == {"node_id": ["evaluate"], "iteration_path": ["[]"], "limit": ["1"]}
+
+
+@pytest.mark.parametrize("status", [403, 404])
+@pytest.mark.parametrize("source", ["saved", "run"])
+def test_access_loss_clears_graph_details_and_execution_evidence(workflow_flow_ui, status, source):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ view = open_run_flow(ui) if source == "run" else open_saved(ui)
+ inspector = select_node(view, "evaluate")
+ expect(inspector).to_contain_text("INSTRUCTIONS")
+ path = f"/api/user/workflows/{FLOW_WORKFLOW_ID}"
+ path += f"/runs/{FLOW_RUN_ID}/flow" if source == "run" else "/flow"
+ ui.reject_next("GET", path, status=status, error="The fictional definition is no longer readable.")
+ inspector.get_by_role("button", name="Refresh node details", exact=True).click()
+ expect(page.get_by_role("alert").filter(has_text=re.compile("access|available|readable", re.I)).first).to_be_visible()
+ expect(page.locator("[data-workflow-node-id]")).to_have_count(0)
+ expect(page.get_by_role("region", name="Flow node inspection", exact=True)).to_have_count(0)
+ expect(page.get_by_text(re.compile("FROZEN_INSTRUCTIONS|LIVE_ONLY_INSTRUCTIONS"))).to_have_count(0)
+ expect(page.get_by_role("list", name=re.compile("^Attempts for execution "))).to_have_count(0)
+
+
+def test_read_only_group_source_switch_rejects_delayed_previous_group_projection(workflow_flow_ui):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ ui.group_can_manage = False
+ alpha = ui.group_workflows[GROUP_ID][FLOW_WORKFLOW_ID]
+ beta = ui.group_workflows[SECOND_GROUP_ID][FLOW_WORKFLOW_ID]
+ next(task for task in alpha["tasks"] if task["id"] == "evaluate")["name"] = "Alpha-only evaluation"
+ next(task for task in beta["tasks"] if task["id"] == "evaluate")["name"] = "Beta-only evaluation"
+ ui.hold_next_flow(source_kind="saved", group_id=GROUP_ID)
+ ui.open("/groups")
+ page.get_by_label("Group workspace", exact=True).select_option(GROUP_ID)
+ page.get_by_role("button", name="View Flow for Alpha read-only Flow", exact=True).click()
+ expect(flow_region(page).get_by_text("Loading authorized Flow definition...", exact=True)).to_be_visible()
+ close_saved(page)
+ page.get_by_label("Group workspace", exact=True).select_option(SECOND_GROUP_ID)
+ page.get_by_role("button", name="View Flow for Beta read-only Flow", exact=True).click()
+ view = flow_region(page)
+ expect(node_button(view, "evaluate")).to_have_accessible_name("Select Beta-only evaluation (Task)")
+ inspector = select_node(view, "evaluate")
+ expect(inspector).to_contain_text("Instructions")
+ assert len(ui.held_flow_responses) == 1
+ ui.release_flow_responses()
+ page.wait_for_load_state("networkidle")
+ expect(node_button(view, "evaluate")).to_have_accessible_name("Select Beta-only evaluation (Task)")
+ expect(view.get_by_text("Alpha-only evaluation", exact=True)).to_have_count(0)
+ flows = [entry for entry in ui.requests if entry.path.endswith("/flow")]
+ assert all(entry.path.startswith("/api/group/workflows/") for entry in flows)
+ assert all(entry.query.get("group_id") in ([GROUP_ID], [SECOND_GROUP_ID]) for entry in flows)
+ assert flows[-1].query["group_id"] == [SECOND_GROUP_ID]
+ ui.assert_read_only()
+
+
+def test_group_reader_can_inspect_frozen_run_and_exact_attempt_with_current_scope(workflow_flow_ui):
+ ui = workflow_flow_ui
+ ui.group_can_manage = False
+ view = open_run_flow(ui, workflow_name="Alpha read-only Flow", group=True)
+ expect(node_button(view, "evaluate")).to_have_accessible_name("Select Frozen group evaluation (Task)")
+ inspector = select_node(view, "evaluate")
+ expect(inspector).to_contain_text("FROZEN_GROUP_INSTRUCTIONS")
+ execution_id = ui.execution_for(FLOW_WORKFLOW_ID, FLOW_RUN_ID, "evaluate", [], scope="group")
+ expect(inspector.get_by_role("list", name=f"Attempts for execution {execution_id}", exact=True)).to_contain_text("Attempt 2")
+ requests = [
+ entry for entry in ui.requests
+ if entry.path.startswith(f"/api/group/workflows/{FLOW_WORKFLOW_ID}/runs/{FLOW_RUN_ID}")
+ ]
+ assert requests and all(entry.query.get("group_id") == [GROUP_ID] for entry in requests)
+ assert ui.exact_requests[-1].query == {
+ "node_id": ["evaluate"], "iteration_path": ["[]"], "limit": ["1"], "group_id": [GROUP_ID],
+ }
+ expect(view.get_by_role("button", name=re.compile(r"^(Approve|Retry|Resume|Continue Repeat)$"))).to_have_count(0)
+
+
+def test_selected_details_replace_bounded_pages_without_draining_them(workflow_flow_ui):
+ ui = workflow_flow_ui
+ definition = ui.personal_workflows[FLOW_WORKFLOW_ID]
+ task = next(task for task in definition["tasks"] if task["id"] == "finish")
+ task["inputs"] = [flow_binding(f"input_{index}", "evaluate") for index in range(100)]
+ definition["definition_revision"] = workflow_definition_revision(definition)
+ view = open_saved(ui)
+ assert_no_eager_content(ui)
+ inspector = select_node(view, "finish")
+ inspector.get_by_label("Inspection section", exact=True).select_option("inputs")
+ expect(inspector).to_contain_text("Showing 50 of 100 typed inputs entries")
+ expect(inspector.get_by_text("input_0", exact=True)).to_be_visible()
+ expect(inspector.get_by_text("input_50", exact=True)).to_have_count(0)
+ expect(view.locator(".workflow-flow-data-edge")).to_have_count(1)
+ expect(view.get_by_text("50 declared bindings (this page)", exact=True)).to_have_count(1)
+ initial = [entry for entry in ui.detail_requests if entry.query["section"] == ["inputs"]]
+ assert len(initial) == 1 and "cursor" not in initial[0].query
+ inspector.get_by_role("button", name="Next details page", exact=True).click()
+ expect(inspector.get_by_text("input_50", exact=True)).to_be_visible()
+ expect(inspector.get_by_text("input_0", exact=True)).to_have_count(0)
+ expect(view.get_by_text("50 declared bindings (this page)", exact=True)).to_have_count(1)
+ expect(inspector.get_by_role("button", name="Next details page", exact=True)).to_be_disabled()
+ pages = [entry for entry in ui.detail_requests if entry.query["section"] == ["inputs"]]
+ assert len(pages) == 2 and pages[-1].query.get("cursor")
+ assert all(entry.query["limit"] == ["50"] for entry in pages)
+
+
+def test_reselecting_loaded_node_preserves_its_inspected_bindings(workflow_flow_ui):
+ ui = workflow_flow_ui
+ view = open_saved(ui)
+ inspector = select_node(view, "finish")
+ inspector.get_by_label("Inspection section", exact=True).select_option("inputs")
+ expect(inspector).to_contain_text("Showing 2 of 2 typed inputs entries")
+ expect(view.locator(".workflow-flow-data-edge")).to_have_count(2)
+ requests = len(ui.detail_requests)
+ inspector = select_node(view, "finish")
+ expect(inspector).to_contain_text("Showing 2 of 2 typed inputs entries")
+ expect(view.locator(".workflow-flow-data-edge")).to_have_count(2)
+ assert len(ui.detail_requests) == requests
+
+
+@pytest.mark.parametrize("node_id", ["root", "evaluate"])
+def test_root_and_task_source_selection_show_only_their_bounded_references(workflow_flow_ui, node_id):
+ ui = workflow_flow_ui
+ definition = ui.personal_workflows[FLOW_WORKFLOW_ID]
+ definition["reference_inputs"] = [{
+ "id": f"ref-{index}", "name": f"reference_{index}", "document_id": f"reference-document-{index}",
+ "scope_type": "personal",
+ } for index in range(60)]
+ task = next(task for task in definition["tasks"] if task["id"] == "evaluate")
+ task["reference_ids"] = ["ref-0", "ref-59"]
+ definition["definition_revision"] = workflow_definition_revision(definition)
+ view = open_saved(ui)
+ assert_no_eager_content(ui)
+ assert "reference-document-" not in json.dumps(ui.flow_payloads)
+ inspector = select_node(view, node_id)
+ inspector.get_by_label("Inspection section", exact=True).select_option(label="Source selection")
+ expect(inspector.get_by_text("reference_0", exact=True)).to_be_visible()
+ requests = [entry for entry in ui.detail_requests if entry.query["section"] == ["selection"]]
+ assert len(requests) == 1 and "cursor" not in requests[0].query
+ if node_id == "root":
+ expect(inspector).to_contain_text("Showing 50 of 60 source selection entries")
+ expect(inspector.get_by_text("reference_59", exact=True)).to_have_count(0)
+ inspector.get_by_role("button", name="Next details page", exact=True).click()
+ expect(inspector).to_contain_text("Showing 10 of 60 source selection entries")
+ expect(inspector.get_by_text("reference_0", exact=True)).to_have_count(0)
+ expect(inspector.get_by_text("reference_59", exact=True)).to_be_visible()
+ else:
+ expect(inspector).to_contain_text("Showing 2 of 2 source selection entries")
+ expect(inspector.get_by_text("reference_59", exact=True)).to_be_visible()
+ expect(inspector.get_by_text("reference_1", exact=True)).to_have_count(0)
+ expect(inspector.get_by_role("button", name="Next details page", exact=True)).to_be_disabled()
+ requests = [entry for entry in ui.detail_requests if entry.query["section"] == ["selection"]]
+ assert len(requests) == (2 if node_id == "root" else 1)
+ assert all(entry.query["limit"] == ["50"] for entry in requests)
+ expect(view.locator(".workflow-flow-data-edge")).to_have_count(0)
+ assert not ui.evidence_requests and not ui.exact_requests
+
+
+def test_for_each_source_selection_accepts_5001_entries_without_expanding_instances(workflow_flow_ui):
+ ui = workflow_flow_ui
+ ui.seed_mixed(("for_each", "repeat_until", "for_each"))
+ definition = ui.personal_workflows[MIXED_WORKFLOW_ID]
+ loop = next(node for node in definition["flow"]["nodes"] if node["id"] == "loop-0")
+ loop["inputs"] = []
+ loop["iterable"] = {"kind": "documents", "documents": [{
+ "document_id": f"selection-document-{index:04d}", "scope_type": "personal",
+ } for index in range(5000)]}
+ definition["definition_revision"] = workflow_definition_revision(definition)
+ view = open_saved(ui, MIXED_NAME)
+ assert_no_eager_content(ui)
+ assert view.locator("[data-workflow-node-id]").count() <= 5
+ assert "selection-document-" not in json.dumps(ui.flow_payloads)
+ inspector = select_node(view, "loop-0")
+ inspector.get_by_label("Inspection section", exact=True).select_option(label="Source selection")
+ expect(inspector).to_contain_text("Showing 50 of 5001 source selection entries")
+ expect(inspector.get_by_text("Iterable", exact=True)).to_be_visible()
+ expect(inspector).to_contain_text("selection-document-0000")
+ expect(inspector).not_to_contain_text("selection-document-0049")
+ pages = [entry for entry in ui.detail_requests if entry.query["section"] == ["selection"]]
+ assert len(pages) == 1 and "cursor" not in pages[0].query
+ inspector.get_by_role("button", name="Next details page", exact=True).click()
+ expect(inspector).to_contain_text("selection-document-0049")
+ expect(inspector).to_contain_text("selection-document-0098")
+ expect(inspector).not_to_contain_text("selection-document-0000")
+ pages = [entry for entry in ui.detail_requests if entry.query["section"] == ["selection"]]
+ assert len(pages) == 2 and pages[-1].query.get("cursor")
+ assert all(entry.query["limit"] == ["50"] for entry in pages)
+ assert view.locator("[data-workflow-node-id]").count() <= 5
+ expect(view.locator(".workflow-flow-data-edge")).to_have_count(0)
+ assert not ui.evidence_requests and not ui.exact_requests
+
+
+def test_malicious_labels_remain_text_and_keyboard_inspection_returns_focus(workflow_flow_ui):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ definition = ui.personal_workflows[FLOW_WORKFLOW_ID]
+ next(task for task in definition["tasks"] if task["id"] == "evaluate")["name"] = MALICIOUS_LABEL
+ view = open_saved(ui)
+ root = node_button(view, "root")
+ root.focus()
+ root.press("ArrowDown")
+ chosen = node_button(view, "evaluate")
+ expect(chosen).to_be_focused()
+ page.wait_for_function(
+ "element => new DOMMatrixReadOnly(getComputedStyle(element).transform).a >= 1",
+ arg=view.locator(".react-flow__viewport").element_handle(), timeout=5000,
+ )
+ expect(chosen).to_have_accessible_name(f"Select {MALICIOUS_LABEL} (Task)")
+ chosen.press("Enter")
+ expect(chosen).to_be_focused()
+ view.get_by_role("button", name="Inspect selected node", exact=True).click()
+ inspector = view.get_by_role("region", name="Flow node inspection", exact=True)
+ expect(inspector).to_be_focused()
+ inspector.get_by_role("button", name="Return to selected node", exact=True).click()
+ expect(chosen).to_be_focused()
+ chosen.press("ArrowLeft")
+ expect(root).to_be_focused()
+ assert page.evaluate("window.flowLabelExecuted === undefined")
+ expect(view.locator("img")).to_have_count(0)
+ assert all(not entry.path.endswith("/x") for entry in ui.requests)
+
+
+def test_keyboard_navigation_follows_regions_without_changing_executable_order(workflow_flow_ui):
+ ui = workflow_flow_ui
+ original = copy.deepcopy(ui.personal_workflows[FLOW_WORKFLOW_ID])
+ view = open_saved(ui)
+ branch = node_button(view, "choose")
+ branch.focus()
+ branch.press("ArrowRight")
+ then = node_button(view, "accepted-path")
+ expect(then).to_be_focused()
+ then.press("ArrowRight")
+ task = node_button(view, "accept")
+ expect(task).to_be_focused()
+ task.press("ArrowLeft")
+ expect(then).to_be_focused()
+ then.press("ArrowLeft")
+ expect(branch).to_be_focused()
+ branch.press("End")
+ expect(node_button(view, "finish")).to_be_focused()
+ node_button(view, "finish").press("Home")
+ expect(node_button(view, "root")).to_be_focused()
+ assert not ui.detail_requests and not ui.evidence_requests
+ assert ui.personal_workflows[FLOW_WORKFLOW_ID] == original
+
+
+@pytest.mark.parametrize("theme", ["light", "dark"])
+def test_saved_mobile_structure_and_diagram_keep_focus_and_fit_the_page(workflow_flow_ui, theme):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ page.emulate_media(reduced_motion="reduce")
+ view = open_saved(ui, width=390, height=844, theme=theme)
+ expect(view.get_by_role("button", name="Structure list", exact=True)).to_have_attribute("aria-pressed", "true")
+ expect(view.get_by_role("list", name="Read-only workflow structure", exact=True)).to_be_visible()
+ expect(view.locator(".workflow-flow-canvas")).to_have_count(0)
+ inspector = select_node(view, "evaluate")
+ view.get_by_role("button", name="Inspect selected node", exact=True).click()
+ expect(inspector).to_be_focused()
+ inspector.get_by_role("button", name="Return to selected node", exact=True).click()
+ expect(node_button(view, "evaluate")).to_be_focused()
+ ui.assert_no_overflow()
+ view.get_by_role("button", name="Flow diagram", exact=True).click()
+ expect(view.locator(".workflow-flow-canvas")).to_be_visible()
+ select_node(view, "evaluate")
+ view.get_by_role("button", name="Fit Flow", exact=True).click()
+ ui.assert_no_overflow()
+ dialog = page.get_by_role("dialog", name="Workflow Flow", exact=True)
+ assert dialog.evaluate("element => element.scrollWidth <= element.clientWidth + 1")
+ assert page.evaluate("matchMedia('(prefers-reduced-motion: reduce)').matches")
+ assert not ui.preview_requests
+ assert all(asset.startswith("/static/") for asset in ui.loaded_assets)
+
+
+@pytest.mark.parametrize("theme", ["light", "dark"])
+def test_mobile_defaults_to_list_and_optional_flow_has_no_page_overflow(workflow_flow_ui, theme):
+ ui, page = workflow_flow_ui, workflow_flow_ui.page
+ page.emulate_media(reduced_motion="reduce")
+ editor = open_editor(ui, width=390, height=844, theme=theme)
+ main = editor.get_by_role("group", name="Main region", exact=True, include_hidden=True)
+ expect(main).to_be_visible()
+ assert not ui.preview_requests
+ ui.assert_no_overflow()
+ view = show_preview(editor)
+ expect(main).to_have_count(1)
+ expect(main).to_be_hidden()
+ expect(view.get_by_role("button", name="Structure list", exact=True)).to_have_attribute("aria-pressed", "true")
+ expect(view.get_by_role("list", name="Read-only workflow structure", exact=True)).to_be_visible()
+ expect(view.locator(".workflow-flow-canvas")).to_have_count(0)
+ inspector = select_node(view, "evaluate")
+ view.get_by_role("button", name="Inspect selected node", exact=True).click()
+ expect(inspector).to_be_focused()
+ inspector.get_by_role("button", name="Return to selected node", exact=True).click()
+ expect(node_button(view, "evaluate")).to_be_focused()
+ view.get_by_role("button", name="Flow diagram", exact=True).click()
+ expect(view.get_by_role("button", name="Flow diagram", exact=True)).to_have_attribute("aria-pressed", "true")
+ expect(view.locator(".workflow-flow-canvas")).to_be_visible()
+ select_node(view, "evaluate")
+ view.get_by_role("button", name="Fit Flow", exact=True).click()
+ ui.assert_no_overflow()
+ assert editor.evaluate("element => element.scrollWidth <= element.clientWidth + 1")
+ assert page.evaluate("matchMedia('(prefers-reduced-motion: reduce)').matches")
+ assert all(asset.startswith("/static/") for asset in ui.loaded_assets)
+ editor.get_by_role("button", name="Hide Flow preview", exact=True).click()
+ expect(editor.get_by_role("region", name="Workflow Flow", exact=True)).to_have_count(0)
+ expect(main).to_be_visible()