Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions application/single_app/admin_settings_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,12 @@
WORKFLOW_LOOP_ITEMS_DEFAULT,
WORKFLOW_LOOP_ITEMS_MAX,
WORKFLOW_LOOP_ITEMS_MIN,
WORKFLOW_REPEAT_ITERATIONS_DEFAULT,
WORKFLOW_REPEAT_ITERATIONS_MAX,
WORKFLOW_REPEAT_ITERATIONS_MIN,
WorkflowLoopLimitError,
validate_workflow_max_loop_items,
validate_workflow_max_repeat_iterations,
)

HEX_COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$")
Expand Down Expand Up @@ -4008,6 +4012,22 @@
"max": WORKFLOW_LOOP_ITEMS_MAX,
"step": 1,
},
{
"key": "workflow_max_repeat_iterations",
"type": "number",
"label": "Workflow Repeat Iteration Limit",
"help": (
"Maximum rounds allowed in one automatic Repeat until batch in a new "
"personal or group workflow run. Authors must choose a per-block maximum; "
"new runs above this ceiling are rejected, never shortened. Active runs "
"and manual continuation keep their admitted limit. Another batch does "
"not reset the run's execution-admission budget or elapsed deadline."
),
"default": WORKFLOW_REPEAT_ITERATIONS_DEFAULT,
"min": WORKFLOW_REPEAT_ITERATIONS_MIN,
"max": WORKFLOW_REPEAT_ITERATIONS_MAX,
"step": 1,
},
],
# --- Agents & Actions -------------------------------------------------
#
Expand Down Expand Up @@ -6582,6 +6602,12 @@ def _normalize_field_value(key, value, field):
except WorkflowLoopLimitError as error:
return None, error.public_message, None

if key == "workflow_max_repeat_iterations":
try:
return validate_workflow_max_repeat_iterations(value), None, None
except WorkflowLoopLimitError as error:
return None, error.public_message, None

if field_type == "switch":
return _coerce_bool(value), None, None

Expand Down
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.119"
VERSION = "0.261.120"
IS_DEVELOPMENT = is_development_env_enabled()

SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
Expand Down
10 changes: 10 additions & 0 deletions application/single_app/functions_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@
from functions_service_health import get_default_service_health
from functions_workflow_limits import (
WORKFLOW_LOOP_ITEMS_DEFAULT,
WORKFLOW_REPEAT_ITERATIONS_DEFAULT,
validate_workflow_max_loop_items,
validate_workflow_max_repeat_iterations,
)
import admin_settings_secret_utils as _secret_utils
import app_settings_cache
Expand Down Expand Up @@ -1355,6 +1357,7 @@ def get_settings(use_cosmos=False, include_source=False):
'require_member_of_workflow_user': False,
'workflow_max_tasks': 50,
'workflow_max_loop_items': WORKFLOW_LOOP_ITEMS_DEFAULT,
'workflow_max_repeat_iterations': WORKFLOW_REPEAT_ITERATIONS_DEFAULT,
'allow_group_workflows': False,
'require_group_assignment_for_group_workflows': False,
'group_workflow_allowed_group_ids': [],
Expand Down Expand Up @@ -2156,6 +2159,13 @@ def update_settings(new_settings):
new_settings['workflow_max_loop_items']
),
}
if isinstance(new_settings, dict) and 'workflow_max_repeat_iterations' in new_settings:
new_settings = {
**new_settings,
'workflow_max_repeat_iterations': validate_workflow_max_repeat_iterations(
new_settings['workflow_max_repeat_iterations']
),
}
screening_write = isinstance(new_settings, dict) and 'enable_content_screening' in new_settings
try:
# The guard imports storage clients only when a settings write is requested.
Expand Down
14 changes: 11 additions & 3 deletions application/single_app/functions_workflow_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@
from functions_workflow_flow import FLOW_LIMITS
from functions_workflow_limits import (
WORKFLOW_LOOP_ITEMS_DEFAULT,
WORKFLOW_REPEAT_ITERATIONS_DEFAULT,
WORKFLOW_REPEAT_ITERATIONS_MAX,
get_workflow_max_loop_items,
get_workflow_max_repeat_iterations,
validate_workflow_max_loop_items,
validate_workflow_max_repeat_iterations,
)


def build_workflow_editor_options(*, scope_type, scope_id, can_manage, max_tasks,
agents, endpoints, default_model=None,
max_loop_items=WORKFLOW_LOOP_ITEMS_DEFAULT):
max_loop_items=WORKFLOW_LOOP_ITEMS_DEFAULT,
max_repeat_iterations=WORKFLOW_REPEAT_ITERATIONS_DEFAULT):
if scope_type not in {"personal", "group"}:
raise ValueError("Unsupported workflow editor scope.")
agent_options = [
Expand Down Expand Up @@ -58,10 +63,10 @@ def build_workflow_editor_options(*, scope_type, scope_id, can_manage, max_tasks
return {
"definition_version": WORKFLOW_DEFINITION_VERSION,
"supported_definition_versions": [1, 2, 3],
"supported_node_kinds": ["task", "if", "route", "for_each", "collect"],
"supported_node_kinds": ["task", "if", "route", "for_each", "collect", "repeat_until"],
"supported_iterable_kinds": ["input", "documents", "workspace_query"],
"supported_query_modes": ["all_matches", "best_n"],
"supported_binding_sources": ["node_output", "loop_item"],
"supported_binding_sources": ["node_output", "loop_item", "repeat_state"],
"supported_input_processing_modes": sorted(WORKFLOW_INPUT_PROCESSING_MODES),
"supported_publication_completion_policies": list(WORKFLOW_PUBLICATION_COMPLETION_POLICIES),
"publication_source_capabilities": [
Expand All @@ -73,6 +78,8 @@ def build_workflow_editor_options(*, scope_type, scope_id, can_manage, max_tasks
"flow_limits": {
**FLOW_LIMITS,
"max_loop_items": validate_workflow_max_loop_items(max_loop_items),
"max_repeat_iterations": validate_workflow_max_repeat_iterations(max_repeat_iterations),
"hard_repeat_iterations": WORKFLOW_REPEAT_ITERATIONS_MAX,
},
"scope": {"type": scope_type, "id": str(scope_id)},
"can_manage": bool(can_manage),
Expand Down Expand Up @@ -124,4 +131,5 @@ def get_workflow_editor_options(user_id, settings, *, group_id=""):
can_manage=can_manage, max_tasks=get_workflow_max_tasks(settings),
agents=agents, endpoints=endpoints, default_model=_build_default_model_summary(settings),
max_loop_items=get_workflow_max_loop_items(settings),
max_repeat_iterations=get_workflow_max_repeat_iterations(settings),
)
59 changes: 30 additions & 29 deletions application/single_app/functions_workflow_execution_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,50 @@

from functions_analysis_access import authorize_analysis_sources, build_analysis_access
from functions_workflow_identity import workflow_node_identity
from functions_workflow_node_results import authorize_workflow_node_result_read, result_selectors
from functions_workflow_limits import WORKFLOW_MAX_EXECUTION_ADMISSIONS
from functions_workflow_node_results import (
WorkflowLineageAuthorization, authorize_workflow_node_result_read, load_node_result, result_selectors,
)
from functions_workflow_result_store import read_workflow_node_result_page
from functions_workflow_runtime_store import workflow_runtime_store


def authorize_execution_payload(workflow, run_id, payload, *, reader_user_id):
def authorize_execution_payload(workflow, run_id, payload, *, reader_user_id, authorization=None):
authorization = authorization or WorkflowLineageAuthorization(
workflow, run_id, reader_user_id=reader_user_id, store=workflow_runtime_store(workflow, run_id),
)
if payload.get("iteration_path"):
from functions_workflow_iterations import authorize_iteration_path

authorize_iteration_path(
workflow, run_id, payload, reader_user_id=reader_user_id,
receipts=payload.get("iteration_inputs") or [],
)
authorization.walk([("path", payload, payload.get("iteration_inputs") or [])])
if payload.get("node_kind") == "for_each":
from functions_workflow_iterations import authorize_frozen_loop

store = workflow_runtime_store(workflow, run_id)
store = authorization.store
loop = store.journal_read("loop", payload["execution_id"])
if loop:
authorize_frozen_loop(
workflow, run_id, {"producer": loop["payload"]["identity"], "manifest_ref": loop["payload"]["manifest_ref"]},
reader_user_id=reader_user_id, store=store,
)
authorization.walk([("frozen", {
"producer": loop["payload"]["identity"], "manifest_ref": loop["payload"]["manifest_ref"],
})])
if payload.get("node_kind") == "repeat_until" or payload.get("decision_kind") == "repeat_transition" or payload.get("repeat"):
loop = authorization.store.journal_read("loop", payload["execution_id"])
if loop:
head = loop["payload"]
reference = payload.get("after_state_ref") or head["current_state_ref"]
authorization.authorize_repeat(head["identity"], reference)
references = payload.get("reference_sources") or []
if references:
policy = build_analysis_access(references)
authorize_analysis_sources(reader_user_id, policy["sources"])
summary = payload.get("workflow_result") or {}
if summary.get("result_ref"):
authorize_workflow_node_result_read(
workflow, run_id, summary["producer"], summary["result_ref"], reader_user_id=reader_user_id,
)
for receipt in payload.get("consumed_inputs") or []:
authorize_workflow_node_result_read(
workflow, run_id, receipt["producer"], receipt["result_ref"], reader_user_id=reader_user_id,
)
authorization.authorize_result(summary["producer"], summary["result_ref"])
authorization.walk(("receipt", receipt) for receipt in payload.get("consumed_inputs") or [])


def workflow_execution_history(workflow, run_id, *, reader_user_id, kind="execution", execution_id=None,
cursor=None, limit=50):
cursor=None, limit=50, authorization=None):
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 execution_id:
execution = store.journal_read("execution", execution_id)
if execution is None:
Expand All @@ -55,7 +55,10 @@ def workflow_execution_history(workflow, run_id, *, reader_user_id, kind="execut
# Read the bound internal records too: safe decision projections intentionally omit source references.
for item in page["items"]:
if kind == "decision":
key = ["gate", item["gate_id"]] if item.get("gate_id") else ["control", item["execution_id"]]
key = (
["repeat-transition", item["execution_id"], item["iteration"]] if item.get("decision_kind") == "repeat_transition"
else ["gate", item["gate_id"]] if item.get("gate_id") else ["control", item["execution_id"]]
)
row = store.journal_read("decision", key)
payload = row["payload"]
else:
Expand All @@ -64,7 +67,7 @@ def workflow_execution_history(workflow, run_id, *, reader_user_id, kind="execut
if row is None:
raise LookupError("The execution journal changed while it was being read.")
payload = row["payload"]
authorize_execution_payload(workflow, run_id, payload, reader_user_id=reader_user_id)
authorize_execution_payload(workflow, run_id, payload, reader_user_id=reader_user_id, authorization=authorization)
name = {"execution": "executions", "attempt": "attempts", "decision": "decisions"}[kind]
result = {name: page["items"], "next_cursor": page["next_cursor"]}
if kind == "execution":
Expand Down Expand Up @@ -96,14 +99,12 @@ def workflow_execution_result_page(workflow, run_id, execution_id, attempt, *, r
if not reference:
raise LookupError("The selected output was not produced.")
descriptor = (manifest.get("outputs") or {}).get(name) or {}
for _ in range(256):
for _ in range(WORKFLOW_MAX_EXECUTION_ADMISSIONS):
selected = descriptor.get("selected_producer")
if not selected or name == "manifest":
break
identity = selected["producer"]
manifest, _ = authorize_workflow_node_result_read(
workflow, run_id, identity, selected["result_ref"], reader_user_id=reader_user_id,
)
manifest = load_node_result(workflow, run_id, identity, selected["result_ref"])
descriptor = (manifest.get("outputs") or {}).get(selected["output_name"]) or {}
if descriptor.get("result_ref") != selected["output_ref"]:
raise ValueError("The selected producer output changed.")
Expand Down
Loading
Loading