From 17d2a5cc748712b98d983a6f8aac98704a9f394e Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 9 Oct 2026 14:45:15 -0400 Subject: [PATCH] Fix shared workflow schedule policy and save error visibility Use the general administrator cadence minimum for proposals, replay, and editor saves. Retire the chat-only setting, clarify missing timing, and reveal save failures without losing drafts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../single_app/admin_settings_fields.py | 29 +--- application/single_app/config.py | 2 +- .../functions_orchestration_planner.py | 7 + .../functions_personal_workflows.py | 11 +- application/single_app/functions_settings.py | 16 +- .../functions_workflow_definitions.py | 2 +- .../single_app/functions_workflow_limits.py | 53 +----- .../functions_workflow_schedules.py | 7 +- .../single_app/route_backend_orchestration.py | 2 +- .../route_frontend_admin_settings.py | 3 - .../admin/_panes/chat-orchestration.html | 6 - .../templates/admin/_panes/workflow.html | 3 +- .../components/chat/WorkflowProposalCard.tsx | 2 +- .../workflows/WorkflowEditorDialog.tsx | 24 ++- docs/admin/orchestration.md | 11 +- docs/admin/workflow.md | 2 +- .../CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md | 5 +- .../CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md | 20 ++- .../features/WORKFLOW_DRAFT_SERVICE.md | 18 +- .../features/WORKFLOW_PLAN_REPLAY.md | 12 +- .../WORKFLOW_SHARED_SCHEDULE_POLICY_FIX.md | 85 ++++++++++ docs/guides/create-a-workflow.md | 14 +- ...orchestration_workflow_planning_context.py | 18 +- ...ion_workflow_proposal_editor_round_trip.py | 46 ++++- ...chestration_workflow_propose_capability.py | 70 ++++++-- .../workflow_proposal_draft_probe.ts | 10 +- .../test_workflow_draft_service.py | 38 +++-- ..._workflow_orchestration_limits_settings.py | 157 +++++++++++------- .../test_workflow_plan_replay_routes.py | 27 ++- ..._admin_orchestration_workflow_proposals.py | 5 +- ...v2_orchestration_workflow_proposal_card.py | 10 +- ui_tests/test_v2_workflow_ask_ai_proposal.py | 102 +++++++++++- 32 files changed, 562 insertions(+), 255 deletions(-) create mode 100644 docs/explanation/fixes/WORKFLOW_SHARED_SCHEDULE_POLICY_FIX.md diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py index 036d4a59a..9e6c8fd17 100644 --- a/application/single_app/admin_settings_fields.py +++ b/application/single_app/admin_settings_fields.py @@ -208,9 +208,6 @@ CHAT_ORCHESTRATION_MAX_WORKFLOWS_DEFAULT, CHAT_ORCHESTRATION_MAX_WORKFLOWS_MAX, CHAT_ORCHESTRATION_MAX_WORKFLOWS_MIN, - CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_DEFAULT, - CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_MAX, - CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_MIN, CHAT_ORCHESTRATION_WORKFLOW_RUN_WAIT_SECONDS_DEFAULT, CHAT_ORCHESTRATION_WORKFLOW_RUN_WAIT_SECONDS_MAX, CHAT_ORCHESTRATION_WORKFLOW_RUN_WAIT_SECONDS_MIN, @@ -226,7 +223,6 @@ WorkflowLoopLimitError, validate_chat_orchestration_max_workflow_handoffs_per_day, validate_chat_orchestration_max_workflows_per_user, - validate_chat_orchestration_min_workflow_interval_seconds, validate_chat_orchestration_workflow_run_wait_max_seconds, validate_workflow_max_loop_items, validate_workflow_max_repeat_iterations, @@ -4580,23 +4576,6 @@ "depends_on": {"key": "enable_chat_orchestration", "equals": True}, "group": {"id": "limits", "label": "Limits", "variant": "limits"}, }, - { - "key": "chat_orchestration_min_workflow_interval_seconds", - "type": "number", - "label": "Minimum Schedule Interval For Workflows Created From Chat (seconds)", - "help": ( - "The shortest repeat interval a chat plan may give a workflow it creates. The " - "general Workflow Minimum Schedule Interval still applies when it is longer. " - "Daily, weekly and monthly schedules always pass. Default is 3,600 (hourly); " - "supported range is 60-86,400." - ), - "default": CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_DEFAULT, - "min": CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_MIN, - "max": CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_MAX, - "step": 1, - "depends_on": {"key": "enable_chat_orchestration", "equals": True}, - "group": {"id": "limits", "label": "Limits", "variant": "limits"}, - }, { "key": "chat_orchestration_max_workflow_handoffs_per_day", "type": "number", @@ -4893,7 +4872,7 @@ "label": "Workflow Minimum Schedule Interval (seconds)", "help": ( "Shortest repeat interval, in seconds, allowed when a personal or group " - "workflow interval schedule is created or changed. Workflows already saved " + "workflow interval schedule is created or changed, including workflows from chat. Workflows already saved " "keep their schedule and keep running when this is raised. Calendar " "schedules are not affected. Default is 1; supported range is 1-86,400." ), @@ -9876,12 +9855,6 @@ def _normalize_field_value(key, value, field): except WorkflowLoopLimitError as error: return None, error.public_message, None - if key == "chat_orchestration_min_workflow_interval_seconds": - try: - return validate_chat_orchestration_min_workflow_interval_seconds(value), None, None - except WorkflowLoopLimitError as error: - return None, error.public_message, None - if key == "m365_trusted_download_hosts": try: hosts = normalize_m365_transport_settings("auto", value)[key] diff --git a/application/single_app/config.py b/application/single_app/config.py index 9125c2757..3ba6aef1f 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.316" +VERSION = "0.261.317" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_orchestration_planner.py b/application/single_app/functions_orchestration_planner.py index 6c5d8dbc8..9a6b6944c 100644 --- a/application/single_app/functions_orchestration_planner.py +++ b/application/single_app/functions_orchestration_planner.py @@ -283,6 +283,13 @@ def resolve_planner_client(settings): workflow_propose step that lists it in "delivers". Nothing is created until the user approves the proposal card shown after the answer, so never say that a workflow was created or scheduled. +Honor an explicitly requested cadence when it meets limits.min_interval_seconds; never replace +every-minute timing with hourly timing. When recurring timing is missing, use the existing +elicitation shape to ask how often it should run, with relevant allowed choices such as every +minute, every hour, daily, or weekly. Do not invent a cadence. If the requested interval is below +the administrator's minimum, explain the minimum and ask for an allowed alternative; do not +silently lengthen it. Do not add policy warnings when the requested schedule is allowed. + The workflow_propose step takes no "depends_on" and no "inputs", and no other step, input binding or final_response may name it or its output. When the request also wants a result now, answer it once with the usual steps and select that answer as final_response; otherwise a short compose answer diff --git a/application/single_app/functions_personal_workflows.py b/application/single_app/functions_personal_workflows.py index 7068b7a93..e769d2da3 100644 --- a/application/single_app/functions_personal_workflows.py +++ b/application/single_app/functions_personal_workflows.py @@ -62,12 +62,10 @@ normalize_workflow_definition, workflow_definition_for_editor, ) from functions_workflow_limits import ( - get_orchestration_workflow_min_interval_seconds, get_workflow_min_schedule_interval_seconds, ) from functions_workflow_runtime_store import workflow_runtime_store from functions_workflow_schedules import ( - enforce_orchestration_workflow_cadence, enforce_workflow_schedule_minimum, is_calendar_workflow_schedule, next_workflow_schedule_run, @@ -167,16 +165,13 @@ def _normalize_personal_workflow_conversation_id(user_id, workflow_data, existin return conversation_id -def _normalize_schedule(schedule_payload, existing_workflow=None, settings=None, orchestration=False): +def _normalize_schedule(schedule_payload, existing_workflow=None, settings=None): """Normalize an interval or calendar schedule for saving. The administrator's minimum interval governs only a new or changed interval schedule, so a - raised minimum never blocks re-saving a workflow on the interval it already runs on. A workflow - that chat orchestration is creating must also meet the higher minimum for such workflows. + raised minimum never blocks re-saving a workflow on the interval it already runs on. """ schedule = normalize_workflow_schedule(schedule_payload) - if orchestration: - enforce_orchestration_workflow_cadence(schedule, get_orchestration_workflow_min_interval_seconds(settings)) if workflow_schedule_minimum_applies(schedule, existing_workflow): enforce_workflow_schedule_minimum(schedule, get_workflow_min_schedule_interval_seconds(settings)) return schedule @@ -1103,8 +1098,6 @@ def build_personal_workflow_document(user_id, workflow_data, actor_user_id=None, workflow_data.get('schedule'), existing_workflow=existing_workflow, settings=settings, - # A saved chat plan keeps the chat-workflow floor whenever its schedule is saved. - **({'orchestration': True} if origin is not None or existing_plan_replay_task is not None else {}), ) workflow = { diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 3cffa77ac..7b25b5929 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -87,14 +87,12 @@ from functions_workflow_limits import ( CHAT_ORCHESTRATION_MAX_HANDOFFS_PER_DAY_DEFAULT, CHAT_ORCHESTRATION_MAX_WORKFLOWS_DEFAULT, - CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_DEFAULT, CHAT_ORCHESTRATION_WORKFLOW_RUN_WAIT_SECONDS_DEFAULT, WORKFLOW_LOOP_ITEMS_DEFAULT, WORKFLOW_MIN_SCHEDULE_INTERVAL_DEFAULT, WORKFLOW_REPEAT_ITERATIONS_DEFAULT, validate_chat_orchestration_max_workflow_handoffs_per_day, validate_chat_orchestration_max_workflows_per_user, - validate_chat_orchestration_min_workflow_interval_seconds, validate_chat_orchestration_workflow_run_wait_max_seconds, validate_workflow_max_loop_items, validate_workflow_max_repeat_iterations, @@ -269,6 +267,7 @@ def normalize_public_workspace_display_settings(settings): # Switches that no longer exist. A stored value is removed on load so it is not echoed to # the browser, and so the V2 admin page stops drawing a switch that changes nothing. RETIRED_SETTING_KEYS = ( + "chat_orchestration_min_workflow_interval_seconds", # Gather / Reason / Render became the only chat orchestration plan contract. Earlier # releases already left it out of a saved run's settings fingerprint, so removing it # does not change that fingerprint. @@ -1660,10 +1659,8 @@ def get_settings(use_cosmos=False, include_source=False): # as that user on a schedule. Off by default; turning it off makes existing replay # workflows refuse to run with a fixed reason rather than deleting them. 'enable_workflow_plan_replay': False, - # Workflows that a chat plan creates for a user: how many one user may hold, and the - # shortest interval one may run on (also never shorter than the general workflow minimum). + # How many workflows created from chat one user may hold. 'chat_orchestration_max_workflows_per_user': CHAT_ORCHESTRATION_MAX_WORKFLOWS_DEFAULT, - 'chat_orchestration_min_workflow_interval_seconds': CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_DEFAULT, # How many hand-offs one user may start from chat in a rolling 24 hours. 'chat_orchestration_max_workflow_handoffs_per_day': CHAT_ORCHESTRATION_MAX_HANDOFFS_PER_DAY_DEFAULT, # Planner model binding. Unset falls back to the deployment's default chat model, @@ -2527,15 +2524,6 @@ def update_settings(new_settings, *, expected_etag=None): new_settings['chat_orchestration_max_workflows_per_user'] ), } - if isinstance(new_settings, dict) and 'chat_orchestration_min_workflow_interval_seconds' in new_settings: - new_settings = { - **new_settings, - 'chat_orchestration_min_workflow_interval_seconds': ( - validate_chat_orchestration_min_workflow_interval_seconds( - new_settings['chat_orchestration_min_workflow_interval_seconds'] - ) - ), - } expected_etag = expected_etag or new_settings.get("_etag") updates = { key: copy.deepcopy(value) diff --git a/application/single_app/functions_workflow_definitions.py b/application/single_app/functions_workflow_definitions.py index 3d6b3298c..11f7ce6de 100644 --- a/application/single_app/functions_workflow_definitions.py +++ b/application/single_app/functions_workflow_definitions.py @@ -104,7 +104,7 @@ def __init__(self, public_message=WORKFLOW_FILE_SYNC_SOURCE_UNAVAILABLE_MESSAGE) class WorkflowCadenceError(WorkflowPublicValidationError): - """A workflow created from chat would run more often than its own administrator minimum allows.""" + """A proposed workflow would run more often than the shared administrator minimum allows.""" code = "cadence_below_minimum" diff --git a/application/single_app/functions_workflow_limits.py b/application/single_app/functions_workflow_limits.py index b3c71e635..c9f280912 100644 --- a/application/single_app/functions_workflow_limits.py +++ b/application/single_app/functions_workflow_limits.py @@ -18,16 +18,11 @@ WORKFLOW_MIN_SCHEDULE_INTERVAL_MIN = 1 WORKFLOW_MIN_SCHEDULE_INTERVAL_MAX = 86400 WORKFLOW_MIN_SCHEDULE_INTERVAL_SETTING = "workflow_min_schedule_interval_seconds" -# Workflows that chat orchestration creates for a user have their own cap and a higher floor, so a -# conversation cannot fill a workspace with workflows or schedule one to run every minute. +# Workflows that chat orchestration creates for a user have their own count cap. CHAT_ORCHESTRATION_MAX_WORKFLOWS_DEFAULT = 20 CHAT_ORCHESTRATION_MAX_WORKFLOWS_MIN = 1 CHAT_ORCHESTRATION_MAX_WORKFLOWS_MAX = 100 CHAT_ORCHESTRATION_MAX_WORKFLOWS_SETTING = "chat_orchestration_max_workflows_per_user" -CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_DEFAULT = 3600 -CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_MIN = 60 -CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_MAX = 86400 -CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_SETTING = "chat_orchestration_min_workflow_interval_seconds" # Each accepted hand-off creates a one-time workflow and queues a durable run, so a rolling 24-hour # limit per user bounds how much background work chat can start, separately from the cap above. CHAT_ORCHESTRATION_MAX_HANDOFFS_PER_DAY_DEFAULT = 5 @@ -289,51 +284,9 @@ def get_chat_orchestration_workflow_run_wait_max_seconds(settings=None): ) -def validate_chat_orchestration_min_workflow_interval_seconds(value): - """Validate the shortest interval a workflow created from chat may run on.""" - candidate = _whole_number(value) - if ( - candidate is None - or not CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_MIN <= candidate <= CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_MAX - ): - raise WorkflowLoopLimitError( - "Minimum Schedule Interval For Workflows Created From Chat must be a whole number of " - "seconds from 60 to 86,400.", - code="chat_orchestration_workflow_interval_invalid", - ) - return candidate - - -def get_chat_orchestration_min_workflow_interval_seconds(settings=None): - """Read the orchestration schedule floor on its own, before the general floor is applied.""" - if settings is None: - # Settings initialize application clients; load them only at a request boundary. - from functions_settings import get_settings - - settings = get_settings() - if not isinstance(settings, Mapping): - raise WorkflowLoopLimitError( - "The schedule minimum for workflows created from chat is temporarily unavailable.", - code="chat_orchestration_workflow_interval_unavailable", - ) - return validate_chat_orchestration_min_workflow_interval_seconds( - settings.get( - CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_SETTING, CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_DEFAULT, - ) - ) - - def get_orchestration_workflow_min_interval_seconds(settings=None): - """The shortest interval an orchestration-created workflow may use: the larger of both floors.""" - if settings is None: - # Settings initialize application clients; load them only at a request boundary. - from functions_settings import get_settings - - settings = get_settings() - return max( - get_chat_orchestration_min_workflow_interval_seconds(settings), - get_workflow_min_schedule_interval_seconds(settings), - ) + """Use the same administrator minimum for chat planning as for every workflow editor.""" + return get_workflow_min_schedule_interval_seconds(settings) def effective_workflow_loop_limit(max_items=None, *, settings=None, policy=None): diff --git a/application/single_app/functions_workflow_schedules.py b/application/single_app/functions_workflow_schedules.py index 51dcd1f89..1f70e3d1f 100644 --- a/application/single_app/functions_workflow_schedules.py +++ b/application/single_app/functions_workflow_schedules.py @@ -228,7 +228,7 @@ def enforce_workflow_schedule_minimum(schedule, min_interval_seconds): def enforce_orchestration_workflow_cadence(schedule, min_interval_seconds): - """Refuse an interval that runs a workflow created from chat more often than its own floor. + """Report a blueprint interval below the shared policy with its repairable cadence code. Calendar schedules repeat at most daily, so they always pass. """ @@ -236,9 +236,8 @@ def enforce_orchestration_workflow_cadence(schedule, min_interval_seconds): if seconds is not None and seconds < min_interval_seconds: # The minimum is administrator policy, not caller text, so naming it is safe. raise WorkflowCadenceError( - 'Workflows created from chat cannot run this often. ' - f'Choose an interval of at least {format_workflow_schedule_duration(min_interval_seconds)}, ' - 'or a daily, weekly or monthly schedule.' + 'This schedule runs more often than the administrator allows. ' + f'Choose an interval of at least {format_workflow_schedule_duration(min_interval_seconds)}.' ) return schedule diff --git a/application/single_app/route_backend_orchestration.py b/application/single_app/route_backend_orchestration.py index 370bfd5d9..1cc2abf44 100644 --- a/application/single_app/route_backend_orchestration.py +++ b/application/single_app/route_backend_orchestration.py @@ -3240,7 +3240,7 @@ def _plan_replay_response(run_id, conversation_id, decide): body, code = replay.plan_replay_error_response(exc.code, exc.public_message, exc.refusals) return jsonify(body), code except replay.WorkflowPublicValidationError as exc: - # A reviewed, data-free settings error, such as a schedule faster than the chat minimum. + # A reviewed, data-free settings error, such as a schedule below the shared minimum. return jsonify({'error': exc.public_message, 'code': exc.code}), 422 except ValueError as exc: log_event( diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 1d14b41cf..5afadf2e2 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -549,9 +549,6 @@ def _clamped(field, default, low, high): 'chat_orchestration_max_workflows_per_user': _clamped( 'chat_orchestration_max_workflows_per_user', 20, 1, 100 ), - 'chat_orchestration_min_workflow_interval_seconds': _clamped( - 'chat_orchestration_min_workflow_interval_seconds', 3600, 60, 86400 - ), 'chat_orchestration_max_workflow_handoffs_per_day': _clamped( 'chat_orchestration_max_workflow_handoffs_per_day', 5, 1, 100 ), diff --git a/application/single_app/templates/admin/_panes/chat-orchestration.html b/application/single_app/templates/admin/_panes/chat-orchestration.html index 5db49d9a4..1ed9ad7b2 100644 --- a/application/single_app/templates/admin/_panes/chat-orchestration.html +++ b/application/single_app/templates/admin/_panes/chat-orchestration.html @@ -251,12 +251,6 @@
value="{{ settings.chat_orchestration_max_workflows_per_user if settings.chat_orchestration_max_workflows_per_user is not none else 20 }}">
The most workflows a chat plan may create for one user. Workflows built in the workflow editor do not count.
-
- - -
The shortest repeat interval a chat plan may give a workflow it creates. Daily, weekly and monthly schedules always pass.
-
value="{{ settings.workflow_min_schedule_interval_seconds|default(1) }}" />
Shortest repeat interval, in seconds, allowed when a personal or group workflow - interval schedule is created or changed. Default is 1; supported range is 1-86,400. + interval schedule is created or changed, including workflows from chat. + Default is 1; supported range is 1-86,400. Workflows already saved keep their schedule and keep running when this is raised. Calendar schedules are not affected.
diff --git a/application/v2_ui/src/components/chat/WorkflowProposalCard.tsx b/application/v2_ui/src/components/chat/WorkflowProposalCard.tsx index 9db5bf790..8c18ba5ad 100644 --- a/application/v2_ui/src/components/chat/WorkflowProposalCard.tsx +++ b/application/v2_ui/src/components/chat/WorkflowProposalCard.tsx @@ -65,7 +65,7 @@ const REASON_TEXT: Record = { agent_unavailable: 'The agent this workflow uses is no longer available to you.', file_sync_source_unavailable: 'A File Sync source this workflow uses is no longer available to you.', reference_unavailable: 'A document this workflow uses is no longer available to you.', - cadence_below_minimum: 'This schedule runs more often than workflows created from chat allow.', + cadence_below_minimum: 'This schedule runs more often than the administrator allows.', proposal_unavailable: 'This proposal cannot be created as planned.', content_review: 'This response is in content review, so its proposal cannot be used.', workflow_proposals_disabled: 'Workflow proposals are turned off.', diff --git a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx index 58148fd17..0081efa46 100644 --- a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx +++ b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx @@ -198,6 +198,7 @@ export function WorkflowEditorDialog({ const draft = history.draft; const [saving, setSaving] = useState(false); const [error, setError] = useState(''); + const [errorSequence, setErrorSequence] = useState(0); const [confirmClose, setConfirmClose] = useState(false); const [confirmStructured, setConfirmStructured] = useState(false); const [accessLost, setAccessLost] = useState(false); @@ -486,15 +487,19 @@ export function WorkflowEditorDialog({ return () => cancelAnimationFrame(frame); }, [assistPending, askAiInputId, askAiToggleId]); - const shownError = useRef(error); + const shownError = useRef({ error, sequence: errorSequence }); useEffect(() => { - const appeared = Boolean(error) && error !== shownError.current; - shownError.current = error; - if (!appeared || !panelOpen || sidePanelBesideEditor()) return; - // Narrow screens show the panel instead of the editor, so a new error brings the editor back. - closeChanges(false); - requestAnimationFrame(() => errorRef.current?.focus()); - }, [error, panelOpen]); + const appeared = Boolean(error) && + (error !== shownError.current.error || errorSequence !== shownError.current.sequence); + shownError.current = { error, sequence: errorSequence }; + if (!appeared) return; + if (panelOpen && !sidePanelBesideEditor()) closeChanges(false); + const frame = requestAnimationFrame(() => { + errorRef.current?.focus({ preventScroll: true }); + errorRef.current?.scrollIntoView({ block: 'nearest' }); + }); + return () => cancelAnimationFrame(frame); + }, [error, errorSequence]); const switchSurface = (next: 'list' | 'flow') => { history.session.closeGroup(); @@ -556,6 +561,7 @@ export function WorkflowEditorDialog({ const save = async (confirmed = false) => { if (interactionDisabled) { setError('Refresh workspace access before saving. Your draft has been retained.'); + setErrorSequence((value) => value + 1); return; } history.session.closeGroup(); @@ -569,6 +575,7 @@ export function WorkflowEditorDialog({ ]; if (currentErrors.length) { setError(currentErrors.join(' ')); + setErrorSequence((value) => value + 1); return; } if (!confirmed && workflowSessionSaveNeedsConfirmation(history.session)) { @@ -598,6 +605,7 @@ export function WorkflowEditorDialog({ // A source deleted since the list loaded: reload it so the editor marks that source. if (workflowErrorCode(cause) === WORKFLOW_FILE_SYNC_SOURCE_UNAVAILABLE_CODE) fileSyncSources.retry(); setError(workflowErrorMessage(cause, 'Could not save the workflow. Your draft has been retained.')); + setErrorSequence((value) => value + 1); } finally { history.session.setSaving(false); setSaving(false); diff --git a/docs/admin/orchestration.md b/docs/admin/orchestration.md index 98965c729..02d8198ad 100644 --- a/docs/admin/orchestration.md +++ b/docs/admin/orchestration.md @@ -637,14 +637,15 @@ context. | Earlier runs shown to the planner | How many previous run summaries the planner can see. Zero disables the activity ledger, not recent message history. Supported range is 0-50. | 10 | `chat_orchestration_ledger_max_runs` | | Earlier-run summary size | Caps the size of that summary. Older runs lose their detail first when the budget is reached. Supported range is 1024-131072 bytes. | 16384 | `chat_orchestration_ledger_max_bytes` | | Workflows created from chat per user | Caps how many workflows a chat plan may create for one user, so a conversation cannot fill a workspace with workflows. Workflows a user builds in the workflow editor never count toward it, and deleting a workflow created from chat frees its place. Supported range is 1-100. | 20 | `chat_orchestration_max_workflows_per_user`; since **0.261.202** | -| Minimum schedule interval for workflows created from chat | The shortest repeat interval a chat plan may give a workflow it creates. When the general **Workflow Minimum Schedule Interval** on the Workflow tab is longer, that one applies instead. Daily, weekly and monthly schedules always pass. It is checked when the workflow is created; the owner's later edits follow the general minimum only. Supported range is 60-86,400 seconds. | 3600 | `chat_orchestration_min_workflow_interval_seconds`; since **0.261.202** | | Hand-offs from chat per user per day | Caps how many hand-offs one user may accept from chat in any rolling 24 hours. Each hand-off creates a one-time workflow and starts a durable run, so this bounds how much background work one user can start from chat. It counts the hand-off workflows that still exist, so deleting one frees its place, and hand-off workflows never count toward **Workflows created from chat per user**. It applies only when **Hand Off Large Work From Chat** is on. Supported range is 1-100. | 5 | `chat_orchestration_max_workflow_handoffs_per_day`; since **0.261.250** | | Wait for a workflow from chat | The longest a plan waits for a quick saved workflow before its answer moves on and the result is posted to the chat later instead. **Run timeout** also bounds every wait, so a plan always keeps time to write its answer. Five minutes covers the short digests people run from chat; raise it when their quick workflows routinely take longer, at the cost of holding answers open longer. It applies only when **Wait For Quick Workflows In Chat** is on. Supported range is 60-1800 seconds. | 300 | `chat_orchestration_workflow_run_wait_max_seconds`; since **0.261.309** | -Since **0.261.202**, **Workflows created from chat per user** and **Minimum schedule -interval for workflows created from chat** are enforced by the workflow draft service -that chat orchestration uses to turn a plan into a saved workflow. Workflows created any -other way are not affected by either one. +**Workflows created from chat per user** is enforced by the workflow draft service; +workflows created in the editor do not count toward it. Since **0.261.317**, every +workflow uses **Workflow Minimum Schedule Interval** (`workflow_min_schedule_interval_seconds`) +on the [Workflow tab]({{ '/admin/workflow/' | relative_url }}). Chat proposals, +Ask AI edits, and saves share that policy. The former chat-only minimum is retired; +its stored value is discarded without changing the general minimum. ### Planner Model {#chat-orchestration-planner-model-section} diff --git a/docs/admin/workflow.md b/docs/admin/workflow.md index c939ecbd5..5832cf6a4 100644 --- a/docs/admin/workflow.md +++ b/docs/admin/workflow.md @@ -64,7 +64,7 @@ visible to its members. Turning one on does not turn on the other. | Workflow Task Limit | Caps the ordered instruction tasks a single workflow may contain. Supported range is 1–100. | 50 | `workflow_max_tasks` | | Workflow Loop Item Limit | Bounds the actual per-item body visits in a For each block, not the number of documents that may be searched. A collection above the effective limit must be narrowed before its body can run; it is never silently trimmed. | 500 | `workflow_max_loop_items`; supported range 1-5,000; applies to new runs | | Workflow Repeat Iteration Limit | Bounds one automatic Repeat until batch, including its first round. Authors must choose an explicit per-block maximum; a new run above this ceiling is rejected rather than shortened. | 25 | `workflow_max_repeat_iterations`; supported range 1-1,000; new runs only; active runs and manual continuation retain the admitted policy | -| Workflow Minimum Schedule Interval (seconds) | Sets the shortest fixed interval a personal or group workflow may be saved with, so no one can schedule a workflow to run every few seconds. It's checked only when an interval schedule is created or changed; workflows already saved on a shorter interval keep running. Calendar schedules run at most once a day and are never checked. The default, 1 second, allows every interval, as before. | 1 | `workflow_min_schedule_interval_seconds`; supported range 1-86,400; new or changed interval schedules only | +| Workflow Minimum Schedule Interval (seconds) | Bounds how often personal and group workflows can repeat, including workflows proposed or replayed from chat. It's checked only when an interval schedule is created or changed; an existing shorter schedule remains editable without changing its cadence. Calendar schedules run at most once a day and are not checked. The default, 1 second, permits every supported interval. | 1 | `workflow_min_schedule_interval_seconds`; supported range 1-86,400; shared by all workflow origins since **0.261.317** | When **Use Workflow Results In Chat** is on, the same gate also controls whether a durable personal workflow started from chat can post its terminal outcome back diff --git a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md index e8c23f647..2fb22b984 100644 --- a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md +++ b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md @@ -895,6 +895,9 @@ that opens into the full alert. - The limits are `chat_orchestration_max_workflows_per_user` (default 20, range 1–100) and `chat_orchestration_min_workflow_interval_seconds` (default 3,600, range 60–86,400). The cadence floor applies only when a workflow is created, and the cap is soft: simultaneous accepts can exceed it by the number in flight. + **Superseded in 0.261.317:** the chat-only cadence setting is retired. All workflows + use `workflow_min_schedule_interval_seconds` for new or changed intervals; + the chat-created workflow count cap is unchanged. - The run-time line is added only when the stored trigger is scheduled (interval or File Sync) and its schedule is a calendar schedule. The line is part of a task's input digest, so after an upgrade from 0.261.193 through 0.261.201, a durable calendar run that was still in progress pauses once when it resumes, with "The saved task inputs @@ -1657,7 +1660,7 @@ tracker, running tag and posted-message footers. The last bullet of the **V2** l | Phase | Decision | Recommendation | |---|---|---| | 1 | Include monthly? Admin minimum interval for new interval schedules? | **Settled in #1561**: monthly is included (day of month, clamped), and `workflow_min_schedule_interval_seconds` applies only to new or changed interval schedules. | -| 2 | Per-user cap and minimum cadence defaults for orchestration-created workflows | **Settled in #1566**: a cap of 20 (range 1–100) and a minimum interval of one hour (range 1 minute to 1 day), both admin-editable. The interval floor applies only when a workflow is created. | +| 2 | Per-user cap and minimum cadence defaults for orchestration-created workflows | **Settled in #1566**, then superseded in **0.261.317**: the cap remains 20 (range 1–100); cadence now uses the shared `workflow_min_schedule_interval_seconds` for new or changed intervals. | | A1 | Keep `/` prompts in assist inputs? Should the image editor's transcript be local only, or stored? | **Settled in #1564**: `/` prompts are hidden, and the image transcript stays in memory for the tab (up to 20 exchanges). | | A2 | Add a "search all my documents" toggle to the plan editor? | A later option, not part of A2. Grounding diagrams and charts in documents is decided: not planned (decision #8). | | 3 | Setting key and default for the assistant; persist the side-channel transcript? | **Settled in #1577**: `enable_workflow_ai_assistant`, on by default, effective only when personal workflows are on and the user passes the WorkflowUser role rule. The transcript isn't stored: the client sends up to 20 completed turns with each request. | diff --git a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md index dcdf6d506..54e8a8d7a 100644 --- a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md +++ b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md @@ -6,6 +6,8 @@ Application version tracking: `application\single_app\config.py`. Native alert authoring implemented in version: **0.261.315**. +Shared schedule policy and save-error visibility implemented in version: **0.261.317**. + Related issue: #1547, part of #1543. Builds on the [Workflow draft service](WORKFLOW_DRAFT_SERVICE.md) (#1545) and [Workflow calendar schedules](WORKFLOW_CALENDAR_SCHEDULES.md) (#1544). The @@ -44,6 +46,13 @@ agent and account connection, not a prerequisite for native alerts. These propos personal workflows and alert their owner, not arbitrary recipients or everyone in a group. Missing monitoring cadence should be clarified rather than invented. +For any recurring request without timing, planner guidance asks how often with +relevant allowed choices instead of assuming hourly. An explicitly requested +allowed cadence, including every minute, should be preserved. A below-policy +request is explained with the general administrator minimum and alternatives; +valid timing needs no warning. These instructions guide model behavior, not a +deterministic guarantee of a live model's response. + The closed blueprint accepts `alerts: {mode: "rules", rules: [...]}` with up to 20 rules. Rules use native severities, conditions and pop-up options; a task scope uses its 1-based position in the blueprint, never a stored task ID. The server checks native alert validation @@ -324,10 +333,13 @@ unavailable agents, documents or sources, and 400 otherwise. - One proposal per plan, and at most five tasks. - **Workflows Created From Chat Per User** (`chat_orchestration_max_workflows_per_user`, default 20). At the cap, proposals aren't offered, and an accept checks again. -- **Minimum Schedule Interval For Workflows Created From Chat (seconds)** - (`chat_orchestration_min_workflow_interval_seconds`, default 3,600). The larger - of it and **Workflow Minimum Schedule Interval** applies. Calendar schedules - always pass. +- **Workflow Minimum Schedule Interval** (`workflow_min_schedule_interval_seconds`, + default 1 second) applies to new or changed interval schedules regardless of + origin. Calendar schedules always pass. The former chat-only setting is retired + without changing the general minimum. +- Save failures reveal and focus their message on desktop and mobile, including + repeated identical failures. The editor retains the draft and AI changes for + correction and retry; creation still requires approval. ### Microsoft 365 diff --git a/docs/explanation/features/WORKFLOW_DRAFT_SERVICE.md b/docs/explanation/features/WORKFLOW_DRAFT_SERVICE.md index de1838522..94a869ff4 100644 --- a/docs/explanation/features/WORKFLOW_DRAFT_SERVICE.md +++ b/docs/explanation/features/WORKFLOW_DRAFT_SERVICE.md @@ -6,6 +6,8 @@ Application version tracking: `application\single_app\config.py`. Native rules-mode alert blueprints implemented in version: **0.261.315**. +Shared schedule policy implemented in version: **0.261.317**. + Related issue: #1545, part of #1543. Builds on [Workflow calendar schedules](WORKFLOW_CALENDAR_SCHEDULES.md) (#1544). @@ -275,7 +277,7 @@ blueprint. At most 10 errors are returned, sorted by path. | `unsupported_field` | A field the schema doesn't define, or one the chosen trigger, frequency or runner doesn't use | The field to remove | | `too_many_tasks` | More than 5 tasks, or more than **Workflow Task Limit** | `/tasks` | | `trigger_invalid` | A trigger or schedule field is wrong; an unknown time zone points at `timezone` | The trigger field | -| `cadence_below_minimum` | An interval shorter than the minimum for workflows created from chat; the message names the minimum | `/trigger` or `/trigger/schedule` | +| `cadence_below_minimum` | A blueprint interval shorter than the shared administrator minimum; the message names the minimum. Edited payload saves use `invalid_workflow_settings` for the same refusal. | `/trigger` or `/trigger/schedule` | | `quota_exceeded` | The user already has the most workflows created from chat allowed | `''` | | `agent_unavailable` | `agent_ref` names an agent the user can't use | Each use | | `reference_unknown` | A handle the request didn't map, or text that isn't a handle | Each use | @@ -363,19 +365,19 @@ revives another record. A save payload can't choose the id. | Setting | Default | Range | Applies | | --- | --- | --- | --- | | `chat_orchestration_max_workflows_per_user` | 20 | 1 to 100 | When a workflow is created from chat | -| `chat_orchestration_min_workflow_interval_seconds` | 3,600 (hourly) | 60 to 86,400 | When a workflow is created from chat | +| `workflow_min_schedule_interval_seconds` | 1 second | 1 to 86,400 | Every new or changed interval schedule, regardless of origin | - The cap counts the user's workflows whose `origin.source` is `orchestration` and that aren't being deleted. Workflows the user builds in the editor never count. A count that fails refuses the create rather than treating the count as zero. Two accepts at the same moment can both pass, so the cap can be exceeded by the number of concurrent accepts. -- The effective minimum interval is the larger of this setting and the general - **Workflow Minimum Schedule Interval**. Calendar schedules run no more than - once a day, so they always pass. -- Both limits apply when a workflow is created from chat. The owner's later edits - follow only the general minimum, like any other workflow. -- Workflows without an orchestration origin are unaffected. +- The shared **Workflow Minimum Schedule Interval** is the only cadence policy. + The retired chat-only key is discarded on settings load/save without changing + that value. Calendar schedules run no more than once a day and always pass. +- The count cap applies only to chat-created workflows. The shared cadence policy + applies to every origin, including later edits. An unchanged existing interval + below a newly raised minimum remains savable. - The V2 admin page refuses a value outside the range. The classic admin page keeps its existing behavior of clamping to the range. diff --git a/docs/explanation/features/WORKFLOW_PLAN_REPLAY.md b/docs/explanation/features/WORKFLOW_PLAN_REPLAY.md index a4989261e..eb84360bb 100644 --- a/docs/explanation/features/WORKFLOW_PLAN_REPLAY.md +++ b/docs/explanation/features/WORKFLOW_PLAN_REPLAY.md @@ -4,6 +4,8 @@ Implemented in version: **0.261.308**. Application version tracking: `application\single_app\config.py`. +Shared schedule policy implemented in version: **0.261.317**. + Related issue: part of #1550, under #1543. Builds on [Chat orchestration workflow proposals](CHAT_ORCHESTRATION_WORKFLOW_PROPOSALS.md), [Chat orchestration workflow runs](CHAT_ORCHESTRATION_WORKFLOW_RUNS.md), @@ -384,10 +386,10 @@ orchestration wait. There is no spend cap, so replay reuses the caps on chat-created workflows: -- The schedule must meet `get_orchestration_workflow_min_interval_seconds`, the - larger of `chat_orchestration_min_workflow_interval_seconds` (one hour by - default) and `workflow_min_schedule_interval_seconds`. It is enforced on every - save of a replay workflow, including later edits. +- New or changed interval schedules must meet `workflow_min_schedule_interval_seconds` + (1 second by default), the same policy as all other workflows. An unchanged + existing shorter interval remains savable after an administrator raises the + minimum. Calendar schedules always pass. - Replay workflows count toward `chat_orchestration_max_workflows_per_user` (20 by default). Over the limit, the save is refused as `quota_exceeded` (429). @@ -467,7 +469,7 @@ builds and run-history redaction against a golden captured from base | --- | --- | | `functions_workflow_plan_replay.py` | New: allowlist, freeze, hash, preview, save, run-time authorization, executor, reconciliation and typed result. | | `functions_workflow_runner.py` | Dispatches a `plan_replay` task to the replay executor in one durable unit, and records the typed result on the run item. | -| `functions_personal_workflows.py` | Keeps the frozen plan read-only on save and applies the chat cadence floor to replay workflows. | +| `functions_personal_workflows.py` | Keeps the frozen plan read-only on save and applies the shared cadence policy to replay workflows. | | `functions_group_workflows.py` | Refuses a `plan_replay` task. | | `functions_orchestration_scheduler.py` | Excludes replay runs from the due-run query. | | `route_backend_orchestration.py` | Preview and save routes; 409 for chat routes on replay runs. | diff --git a/docs/explanation/fixes/WORKFLOW_SHARED_SCHEDULE_POLICY_FIX.md b/docs/explanation/fixes/WORKFLOW_SHARED_SCHEDULE_POLICY_FIX.md new file mode 100644 index 000000000..4b0b8f298 --- /dev/null +++ b/docs/explanation/fixes/WORKFLOW_SHARED_SCHEDULE_POLICY_FIX.md @@ -0,0 +1,85 @@ +# Shared workflow schedule policy and visible save errors + +Fixed in version: **0.261.317**. + +Application version tracking: `application\single_app\config.py`. + +## Issue and root cause + +Editing a chat-proposed hourly workflow with Ask AI to run every minute could +pass editor validation but fail on **Confirm and save**. The editor used +`workflow_min_schedule_interval_seconds`, while proposal acceptance also applied +a stricter chat-only minimum. The administrator's general one-second minimum +therefore did not permit the same schedule on both paths. + +The reported request returned HTTP 400 on 9 October 2026 at 17:50:11 UTC. +App Service console logs recorded `create_from_payload rejected` and +`cadence_below_minimum`, after ordinary personal-workflow dry runs had passed. +This confirms the mismatched policy rejection, not the numeric value of the +stored chat-only setting. No production settings were changed. + +On wide screens the error could remain above the scrolled editor content. +Repeating an identical validation error did not reliably reveal it again. + +## Technical changes + +`functions_workflow_limits.py` now supplies the general minimum to chat planning +and blueprint validation. `functions_personal_workflows.py` uses that same policy +for editor saves, proposal acceptance, and saved-plan replay. The general default +is one second; supported values are 1 through 86,400 seconds. + +`functions_settings.py` discards the retired chat-only key on load and save. +Both Classic and V2 admin forms remove its field. Its old value is not copied to +the general setting. The chat-created workflow count quota is unchanged. + +New or changed interval schedules must meet the general minimum. Existing +unchanged shorter schedules remain editable after a policy increase, including +switching between interval and File Sync with the same schedule. Manual and +calendar schedules retain their existing behavior. + +`functions_orchestration_planner.py` guides the model to preserve explicit allowed +timing, ask how often when recurring timing is missing, and explain below-policy +requests rather than silently substituting an interval. This is prompt guidance, +not a guarantee of live model obedience. + +`WorkflowEditorDialog.tsx` focuses and scrolls save errors into view on desktop +and mobile. Narrow screens reveal the editor rather than leave its error behind +Ask AI or Changes. Repeated identical failures are revealed again. Failed saves +retain the draft and change history, and AI edits still require review. + +## Validation and impact + +`functional_tests/test_orchestration_workflow_proposal_editor_round_trip.py` +uses real editor serialization and production acceptance routes. It covers an +hourly-to-minute edit with general minimums of 1 and 60 seconds, ignored legacy +floors, a refusal at 61 seconds, claim release, corrected retry, and idempotency. +The persisted accepted schedule is exactly `{"unit": "minutes", "value": 1}`. + +`test_workflow_orchestration_limits_settings.py`, +`test_workflow_draft_service.py`, and the workflow planning/capability tests cover +shared policy, retired settings, defaults, quota, and repairable blueprint errors. +Calendar and editor/server parity tests protect existing schedule behavior. + +`ui_tests/test_v2_workflow_ask_ai_proposal.py` uses production React components +and the real Ask AI pipeline with scripted replies. Chromium checks cover +minute-based AI edits through review and save, repeated server and validation +errors, viewport visibility and focus at desktop/mobile widths, retained drafts, +and successful retry. Scripted replies prove the contract path, not model quality. + +The focused proposal/planner/replay/settings suite passed 383 checks, and the +proposal/editor/admin browser suite passed 65 checks. Frontend typechecking and +build, calendar/editor-server parity, route-policy checks, and documentation +coverage/quality checks passed. Test dependencies were isolated from the shared +Python installation, and real-route tests used authentication-first offline +bootstrap to preserve the application's import order. + +Three broader group-editor browser cases remain blocked before the workflow +editor opens: their existing fixture does not handle `GET /api/v2/scope`. +That unrelated fixture was not changed. + +Before the fix, the editor could approve an interval that proposal creation +rejected. After the fix, both use one administrator policy and an unsuccessful +save visibly explains the requirement without discarding the user's work. + +These are local implementation checks. The change does not deploy the application +or repair an already denied or expired proposal; those may require a new proposal. diff --git a/docs/guides/create-a-workflow.md b/docs/guides/create-a-workflow.md index 9f60e2d48..15e5aa782 100644 --- a/docs/guides/create-a-workflow.md +++ b/docs/guides/create-a-workflow.md @@ -4,7 +4,7 @@ title: "Create a workflow" description: "Save a repeatable multi-step task that can run manually or on a schedule." section: "Guides" audience: user -version: "0.261.122" +version: "0.261.317" --- ## What this does @@ -137,6 +137,18 @@ Then choose one of these: edited it, and it stays off unless you turn on **Workflow enabled**. - **Deny** declines the proposal. Nothing is created. +From **0.261.317**, chat uses the same administrator minimum as the workflow editor. +An explicit allowed request such as "every minute" should keep that cadence; +if you ask for recurring work without saying when, planner guidance asks you +how often rather than assuming hourly. This is model guidance, not a guarantee +of a particular model's answer. + +Use **Edit**, then **Ask AI**, to change the schedule. Review the AI changes and +choose **Confirm and save**. If the interval is below policy, the error names the +shortest permitted interval and is brought into view. Your edited draft stays +open; choose an allowed interval and save again. No policy warning is shown for +valid timing. + Once it's created, **Open workflow** opens it in Workflows. If you delete it later, **Create again** creates it again, paused. A proposal expires 14 days after chat made it; after that, ask in chat again. diff --git a/functional_tests/test_orchestration_workflow_planning_context.py b/functional_tests/test_orchestration_workflow_planning_context.py index 5bde78842..a65723ee7 100644 --- a/functional_tests/test_orchestration_workflow_planning_context.py +++ b/functional_tests/test_orchestration_workflow_planning_context.py @@ -2,8 +2,9 @@ # test_orchestration_workflow_planning_context.py """ Functional test for the workflow proposal planning context. -Version: 0.261.207 +Version: 0.261.317 Implemented in: 0.261.207 +Shared schedule policy coverage implemented in: 0.261.317 This test ensures that chat orchestration builds the workflow proposal planning context (the requester's agents, File Sync sources, documents and existing workflows, the @@ -389,6 +390,19 @@ def test_a_failed_read_fails_closed_without_echoing_the_error(wf, monkeypatch, f # The ready context # --------------------------------------------------------------------------- +@pytest.mark.parametrize("minimum", [1, 60, 61, 86400]) +def test_planner_projection_uses_only_the_shared_minimum(wf, minimum): + settings = { + **AGENT_SETTINGS, + "workflow_min_schedule_interval_seconds": minimum, + "chat_orchestration_min_workflow_interval_seconds": "invalid legacy value", + } + context = _build(wf, [], settings) + projection = wf.workflow_planner_projection(context) + assert projection["limits"]["min_interval_seconds"] == minimum + assert "chat_orchestration_min_workflow_interval_seconds" not in json.dumps(projection) + + def test_the_ready_context_is_bounded_handle_only_and_keeps_its_records_on_the_server(wf, monkeypatch): logged = [] monkeypatch.setattr(wf, "log_event", lambda message, **kwargs: logged.append((message, kwargs))) @@ -402,7 +416,7 @@ def test_the_ready_context_is_bounded_handle_only_and_keeps_its_records_on_the_s ) assert context["limits"]["max_tasks"] == 5 assert context["limits"]["quota_used"] == 2 and context["limits"]["quota_limit"] == 20 - assert context["limits"]["min_interval_seconds"] >= 3600 + assert context["limits"]["min_interval_seconds"] == 1 assert context["default_model_valid"] is True projection = wf.workflow_planner_projection(context) diff --git a/functional_tests/test_orchestration_workflow_proposal_editor_round_trip.py b/functional_tests/test_orchestration_workflow_proposal_editor_round_trip.py index fe03c6909..0606cc0f9 100644 --- a/functional_tests/test_orchestration_workflow_proposal_editor_round_trip.py +++ b/functional_tests/test_orchestration_workflow_proposal_editor_round_trip.py @@ -2,8 +2,9 @@ # test_orchestration_workflow_proposal_editor_round_trip.py """ Functional test for editing a workflow proposal in the V2 workflow editor before accepting it. -Version: 0.261.315 +Version: 0.261.317 Implemented in: 0.261.207 +Shared schedule policy coverage implemented in: 0.261.317 The proposal card's Edit opens the draft route's workflow in the V2 workflow editor, and Save accepts the proposal with the editor's payload. The accept route records the workflow as edited @@ -30,6 +31,7 @@ BLUEPRINT, ZONE, accept, + decision, draft, h, login, @@ -141,6 +143,48 @@ def test_a_renamed_proposal_is_recorded_as_edited_and_stored_as_typed(h): assert stored["origin"]["source"] == "orchestration" +@pytest.mark.parametrize("minimum", [1, 60]) +@pytest.mark.parametrize("legacy", [3600, 86400, "invalid"]) +def test_hourly_proposal_can_be_edited_to_every_minute_and_persisted(h, minimum, legacy): + h.settings["workflow_min_schedule_interval_seconds"] = minimum + h.settings["chat_orchestration_min_workflow_interval_seconds"] = legacy + seed_run(h, blueprint=blueprint_with({"type": "interval", "unit": "hours", "value": 1})) + login(h) + workflow = draft_workflow(h) + [payload] = run_probe([{"workflow": workflow, "schedule": {"unit": "minutes", "value": 1}}]) + response = accept(h, workflow=payload) + assert response.status_code == 201, response.get_data(as_text=True) + stored = stored_workflow(h) + assert stored["schedule"] == {"unit": "minutes", "value": 1} + assert stored["origin"]["edited"] is True + assert stored["is_enabled"] is False + + +def test_minute_schedule_below_policy_releases_claim_and_allows_corrected_retry(h): + h.settings["workflow_min_schedule_interval_seconds"] = 61 + seed_run(h, blueprint=blueprint_with({"type": "interval", "unit": "hours", "value": 1})) + login(h) + workflow = draft_workflow(h) + [payload] = run_probe([{"workflow": workflow, "schedule": {"unit": "minutes", "value": 1}}]) + response = accept(h, workflow=payload) + assert response.status_code == 400, response.get_data(as_text=True) + body = response.get_json() + assert body["code"] == "invalid_workflow_settings" + assert body["error"] == ( + "This schedule runs more often than the administrator allows. Choose an interval of at least 61 seconds." + ) + current_decision = decision(h) + assert current_decision is None + assert not h.workflows.items + payload["schedule"] = {"unit": "minutes", "value": 2} + response = accept(h, workflow=payload) + assert response.status_code == 201, response.get_data(as_text=True) + assert stored_workflow(h)["schedule"] == {"unit": "minutes", "value": 2} + repeated = accept(h, workflow=payload) + assert repeated.status_code == 200 + assert len(h.workflows.items) == 1 + + @pytest.mark.parametrize("edit", [False, True]) def test_native_critical_alerts_survive_editor_and_accept(h, edit): blueprint = blueprint_with(TRIGGERS["manual"]) diff --git a/functional_tests/test_orchestration_workflow_propose_capability.py b/functional_tests/test_orchestration_workflow_propose_capability.py index 24393ca4c..a16b35d90 100644 --- a/functional_tests/test_orchestration_workflow_propose_capability.py +++ b/functional_tests/test_orchestration_workflow_propose_capability.py @@ -2,8 +2,9 @@ # test_orchestration_workflow_propose_capability.py """ Functional test for the workflow_propose orchestration capability. -Version: 0.261.315 +Version: 0.261.317 Implemented in: 0.261.207 +Shared schedule policy and clarification coverage implemented in: 0.261.317 Merge tasks added in: 0.261.241 This test ensures that chat orchestration offers workflow_propose only when workflow proposals @@ -186,23 +187,23 @@ def _record(): return SimpleNamespace(calls=[], logs=[]) -def _plan_request(monkeypatch, planning, replies, record, *, edit_context=None): +def _plan_request(monkeypatch, planning, replies, record, *, edit_context=None, message=MESSAGE): """Plan one request through the real planner with scripted planner replies.""" context_module = importlib.import_module("functions_orchestration_context") planner = importlib.import_module("functions_orchestration_planner") services = importlib.import_module("functions_orchestration_services") request_context = context_module.build_capability_request_context( - OWNER, deepcopy(IDENTITY), MESSAGE, deepcopy(AGENTS), deepcopy(ACTIONS), allowed_user_urls=[], + OWNER, deepcopy(IDENTITY), message, deepcopy(AGENTS), deepcopy(ACTIONS), allowed_user_urls=[], native_bridge_for_step=_binding, external_source_admission=_binding, external_source_authorizer=_binding, external_source_preflight=_binding, capture_external_source_configuration=_binding, ) request_context["workflow_planning"] = planning planner_context = context_module.build_planner_context( - MESSAGE, candidates=deepcopy(CANDIDATES), seeds={}, ledger=None, - signals=context_module.build_conversation_signals([], MESSAGE), agents=deepcopy(AGENTS), - original_message=MESSAGE, - request_resolution={"relationship": "new_topic", "resolved_message": MESSAGE}, + message, candidates=deepcopy(CANDIDATES), seeds={}, ledger=None, + signals=context_module.build_conversation_signals([], message), agents=deepcopy(AGENTS), + original_message=message, + request_resolution={"relationship": "new_topic", "resolved_message": message}, actions=deepcopy(ACTIONS), answered_questions=[], memory_context=None, ) planner_context["export_catalog"] = [] @@ -213,7 +214,7 @@ def _plan_request(monkeypatch, planning, replies, record, *, edit_context=None): lambda message, *args, **kwargs: record.logs.append((message, deepcopy(kwargs.get("extra")))), ) return planner.plan_request( - MESSAGE, planner_context, "conversation", OWNER, settings={**BASE_SETTINGS, SETTING: True}, + message, planner_context, "conversation", OWNER, settings={**BASE_SETTINGS, SETTING: True}, authorized_document_ids=["document-record-1"], revision=0, allow_elicitation=True, turn_id="turn", seeds={}, document_labels={"document-record-1": "Weekly priorities.docx"}, request_context=request_context, planner_model=None, existing_results={}, @@ -439,8 +440,11 @@ def test_a_microsoft_365_agent_runs_as_the_user(schema, planning): def test_the_draft_rules_apply_to_every_proposal(schema, planning): - fast = _blueprint(planning, trigger={"type": "interval", "unit": "minutes", "value": 30}) - error = _rejection(schema, planning, [ANSWER, _propose(fast)]) + fast = _blueprint(planning, trigger={"type": "interval", "unit": "minutes", "value": 1}) + policy = {**AGENT_SETTINGS, "workflow_min_schedule_interval_seconds": 61} + with pytest.raises(schema.PlanValidationError) as caught: + _normalize(schema, planning, [ANSWER, _propose(fast)], settings=policy) + error = caught.value assert (error.code, error.rule) == ("workflow_blueprint_invalid", "cadence_below_minimum") mail = _handle(planning, "agents", "Mail helper") many = _blueprint(planning, tasks=[_task(mail, f"Task {index}") for index in range(6)]) @@ -544,6 +548,52 @@ def test_a_stored_proposal_is_rechecked_against_the_closed_schema_only(schema, o # Planning: offer, repair, degrade # --------------------------------------------------------------------------- +def test_missing_recurring_timing_can_elicit_allowed_cadence_choices(monkeypatch, planning): + record = _record() + question = { + "kind": "elicitation", + "message": "How often should this workflow run?", + "requested_schema": { + "type": "object", + "properties": {"cadence": { + "type": "string", "title": "How often?", + "enum": ["Every minute", "Every hour", "Daily", "Weekly"], + }}, + "required": ["cadence"], + }, + } + kind, result = _plan_request( + monkeypatch, planning, [question], record, + message="Keep checking my inbox for urgent messages.", + ) + assert kind == "elicitation" + assert result["requested_schema"]["properties"]["cadence"]["enum"] == [ + "Every minute", "Every hour", "Daily", "Weekly", + ] + system = record.calls[0][0]["content"] + assert "When recurring timing is missing, use the existing" in system + assert "Do not invent a cadence." in system + assert "relevant allowed choices" in system + assert "do not\nsilently lengthen it" in system + assert "Do not add policy warnings when the requested schedule is allowed." in system + + +def test_explicit_every_minute_reaches_a_validated_proposal_unchanged(monkeypatch, planning): + record = _record() + blueprint = _blueprint(planning, trigger={"type": "interval", "unit": "minutes", "value": 1}) + kind, plan = _plan_request( + monkeypatch, planning, [_raw_plan([ANSWER, _propose(blueprint)])], record, + message="Check my inbox every minute.", + ) + assert kind == "plan" + assert _step(plan, "propose")["arguments"]["blueprint"]["trigger"] == { + "type": "interval", "unit": "minutes", "value": 1, + } + system = record.calls[0][0]["content"] + assert "Honor an explicitly requested cadence when it meets limits.min_interval_seconds" in system + assert "every-minute timing with hourly timing" in system + + def test_the_planner_is_offered_the_capability_and_only_the_handle_catalog(monkeypatch, planning): record = _record() kind, plan = _plan_request(monkeypatch, planning, [_raw_plan([ANSWER, _propose(_blueprint(planning))])], record) diff --git a/functional_tests/test_support/workflow_proposal_draft_probe.ts b/functional_tests/test_support/workflow_proposal_draft_probe.ts index 9ba4584f0..f8cdb571e 100644 --- a/functional_tests/test_support/workflow_proposal_draft_probe.ts +++ b/functional_tests/test_support/workflow_proposal_draft_probe.ts @@ -2,7 +2,7 @@ // // Runs the V2 workflow editor's real draft handling on workflow proposal drafts from a JSON file and // prints what the editor would send, so a Python test can hand it to the real accept route. -// Version: 0.261.207 +// Version: 0.261.317 // Implemented in: 0.261.207 // // Bundled with the esbuild the V2 app already provides and executed under node by @@ -21,12 +21,18 @@ import { interface DraftRequest { workflow: Record; name?: string; + schedule?: { unit: 'seconds' | 'minutes' | 'hours'; value: number }; } const scope: WorkflowScope = { type: 'personal' }; const request = JSON.parse(readFileSync(process.argv[2], 'utf-8')) as { drafts: DraftRequest[] }; const saves = request.drafts.map((entry) => { const draft = normalizeWorkflowDefinition(entry.workflow, scope); - return workflowForSave(entry.name === undefined ? draft : { ...draft, name: entry.name }, null, scope); + const edited = { + ...draft, + ...(entry.name === undefined ? {} : { name: entry.name }), + ...(entry.schedule === undefined ? {} : { trigger_type: 'interval' as const, schedule: entry.schedule }), + }; + return workflowForSave(edited, null, scope); }); process.stdout.write(JSON.stringify({ saves })); diff --git a/functional_tests/test_workflow_draft_service.py b/functional_tests/test_workflow_draft_service.py index f02e05c7d..34117826e 100644 --- a/functional_tests/test_workflow_draft_service.py +++ b/functional_tests/test_workflow_draft_service.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional test for the workflow draft service. -Version: 0.261.202 +Version: 0.261.317 Implemented in: 0.261.202 +Shared schedule policy coverage implemented in: 0.261.317 This test ensures that a workflow blueprint proposed from chat: @@ -693,20 +694,23 @@ def test_trigger_invalid_points_at_the_field_to_fix(harness): assert _codes(harness.dry_run(review, REVIEW_HANDLES)) == [("trigger_invalid", "/trigger/schedule/timezone")] -def test_cadence_below_minimum_uses_the_larger_floor_and_passes_calendar_schedules(harness): +def test_cadence_below_minimum_uses_the_shared_floor_and_passes_calendar_schedules(harness): + harness.settings["workflow_min_schedule_interval_seconds"] = 3600 every_30_minutes = _blueprint(trigger={"type": "interval", "unit": "minutes", "value": 30}) result = harness.dry_run(every_30_minutes, EMAIL_HANDLES) assert result["errors"] == [{ "code": "cadence_below_minimum", "message": ( - "Workflows created from chat cannot run this often. Choose an interval of at least 1 hour, " - "or a daily, weekly or monthly schedule." + "This schedule runs more often than the administrator allows. " + "Choose an interval of at least 1 hour." ), "path": "/trigger", }] - assert harness.dry_run(_blueprint(trigger={"type": "interval", "unit": "hours", "value": 1}), EMAIL_HANDLES)["ok"] + allowed_hourly = harness.dry_run( + _blueprint(trigger={"type": "interval", "unit": "hours", "value": 1}), EMAIL_HANDLES, + ) + assert allowed_hourly["ok"] - # The general floor wins when an administrator sets it above the orchestration floor. harness.settings["workflow_min_schedule_interval_seconds"] = 7200 hourly = harness.dry_run(_blueprint(trigger={"type": "interval", "unit": "hours", "value": 1}), EMAIL_HANDLES) assert _codes(hourly) == [("cadence_below_minimum", "/trigger")] @@ -718,13 +722,15 @@ def test_cadence_below_minimum_uses_the_larger_floor_and_passes_calendar_schedul "type": "calendar", "frequency": "daily", "time_of_day": "06:30", "timezone": "Asia/Tokyo", }), EMAIL_HANDLES) assert daily["ok"] is True, daily["errors"] - assert _codes(harness.dry_run(_blueprint(trigger={"type": "interval", "unit": "hours", "value": 23}), EMAIL_HANDLES)) == [ - ("cadence_below_minimum", "/trigger"), - ] + allowed = harness.dry_run( + _blueprint(trigger={"type": "interval", "unit": "minutes", "value": 5}), EMAIL_HANDLES, + ) + assert allowed["ok"] is True, allowed["errors"] review = copy.deepcopy(DOCUMENT_REVIEW) - review["trigger"]["schedule"] = {"kind": "interval", "unit": "minutes", "value": 15} - assert _codes(harness.dry_run(review, REVIEW_HANDLES)) == [("cadence_below_minimum", "/trigger/schedule")] + review["trigger"]["schedule"] = {"kind": "interval", "unit": "minutes", "value": 4} + refused_review = harness.dry_run(review, REVIEW_HANDLES) + assert _codes(refused_review) == [("cadence_below_minimum", "/trigger/schedule")] def test_a_manual_blueprint_has_no_schedule_to_check(harness): @@ -840,8 +846,10 @@ def test_agent_unavailable_applies_the_existing_agent_rules_at_every_use(harness }] # Other checks still run, so one repair round can fix everything at once. both = copy.deepcopy(two_tasks) + harness.settings["workflow_min_schedule_interval_seconds"] = 3600 both["trigger"] = {"type": "interval", "unit": "minutes", "value": 5} - assert [code for code, _ in _codes(harness.dry_run(both, EMAIL_HANDLES))] == [ + refused_both = harness.dry_run(both, EMAIL_HANDLES) + assert [code for code, _ in _codes(refused_both)] == [ "agent_unavailable", "agent_unavailable", "cadence_below_minimum", ] @@ -1133,6 +1141,7 @@ def test_create_never_adopts_or_revives_another_record_under_its_id(harness): def test_a_create_rejected_by_a_draft_check_writes_nothing(harness): + harness.settings["workflow_min_schedule_interval_seconds"] = 3600 result = harness.create(_blueprint(trigger={"type": "interval", "unit": "minutes", "value": 10}), EMAIL_HANDLES) assert (result["created"], _codes(result)) == (False, [("cadence_below_minimum", "/trigger")]) assert harness.writes() == {} @@ -1144,7 +1153,7 @@ def test_a_misconfigured_limit_is_a_server_fault_not_a_draft_error(harness): tasks=[_task("t1", "Check", "Check the queue.")]) for setting, value in ( ("chat_orchestration_max_workflows_per_user", 0), - ("chat_orchestration_min_workflow_interval_seconds", 30), + ("workflow_min_schedule_interval_seconds", 0), ): harness.settings = {**harness.settings, setting: value} with pytest.raises(limit_error): @@ -1221,6 +1230,7 @@ def test_a_group_payload_dry_run_writes_nothing(harness): def test_create_from_an_edited_payload_uses_the_same_id_origin_and_limits(harness): + harness.settings["workflow_min_schedule_interval_seconds"] = 3600 workflow_id = harness.drafts.orchestration_workflow_id(OWNER_ID, PROPOSAL_ID) payload = harness.call( "build_workflow_blueprint_payload", EMAIL_DIGEST, EMAIL_HANDLES, workflow_id=workflow_id, @@ -1238,7 +1248,7 @@ def create(data): "code": "workflow_conflict", "message": "This draft names a different workflow.", "path": "/id", }] too_often = create({**payload, "trigger_type": "interval", "schedule": {"unit": "minutes", "value": 30}}) - assert _codes(too_often) == [("cadence_below_minimum", "")] + assert _codes(too_often) == [("invalid_workflow_settings", "")] assert harness.writes() == {} created = create({**payload, "id": workflow_id}) diff --git a/functional_tests/test_workflow_orchestration_limits_settings.py b/functional_tests/test_workflow_orchestration_limits_settings.py index 6ac502f23..888d9da2d 100644 --- a/functional_tests/test_workflow_orchestration_limits_settings.py +++ b/functional_tests/test_workflow_orchestration_limits_settings.py @@ -2,27 +2,26 @@ #!/usr/bin/env python3 """ Functional test for the admin limits on workflows created from chat. -Version: 0.261.202 +Version: 0.261.317 Implemented in: 0.261.202 +Shared schedule policy and legacy retirement coverage implemented in: 0.261.317 -This test ensures that the two limits chat orchestration workflows use are safe by default and -cannot be stored invalid: +The chat workflow count cap stays independent, but all workflow origins use the general +schedule minimum. The retired chat-only minimum is discarded on settings load/save. * ``chat_orchestration_max_workflows_per_user``: default 20, a whole number from 1 to 100. -* ``chat_orchestration_min_workflow_interval_seconds``: default 3,600 (hourly), 60 to 86,400. +* ``workflow_min_schedule_interval_seconds``: default 1, from 1 to 86,400. The V2 admin API and the settings writer reject an invalid value instead of clamping it. The -Classic admin form clamps it, like every other number on its Chat Orchestration pane. At use, a -missing value reads as its default and a corrupt stored value fails closed. Both fields appear in -the V2 schema only while Chat Orchestration is enabled. The orchestration schedule floor combines -with the general Workflow Minimum Schedule Interval, so the larger floor wins, and calendar -schedules always pass. The draft service tests cover enforcement itself. +Classic admin form clamps its count cap. A corrupt general minimum fails closed; a retired +chat-only value cannot influence it. Calendar schedules always pass. """ import ast import copy import logging import re +import subprocess import sys from contextlib import contextmanager, nullcontext from pathlib import Path @@ -45,16 +44,11 @@ CHAT_ORCHESTRATION_MAX_WORKFLOWS_DEFAULT, CHAT_ORCHESTRATION_MAX_WORKFLOWS_MAX, CHAT_ORCHESTRATION_MAX_WORKFLOWS_MIN, - CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_DEFAULT, - CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_MAX, - CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_MIN, WorkflowLoopLimitError, get_chat_orchestration_max_workflows_per_user, - get_chat_orchestration_min_workflow_interval_seconds, get_orchestration_workflow_min_interval_seconds, validate_chat_orchestration_max_workflow_handoffs_per_day, validate_chat_orchestration_max_workflows_per_user, - validate_chat_orchestration_min_workflow_interval_seconds, validate_workflow_max_loop_items, validate_workflow_max_repeat_iterations, validate_workflow_min_schedule_interval_seconds, @@ -113,9 +107,6 @@ class ClosedError(Exception): "validate_workflow_max_repeat_iterations": validate_workflow_max_repeat_iterations, "validate_workflow_min_schedule_interval_seconds": validate_workflow_min_schedule_interval_seconds, "validate_chat_orchestration_max_workflows_per_user": validate_chat_orchestration_max_workflows_per_user, - "validate_chat_orchestration_min_workflow_interval_seconds": ( - validate_chat_orchestration_min_workflow_interval_seconds - ), "validate_chat_orchestration_max_workflow_handoffs_per_day": ( validate_chat_orchestration_max_workflow_handoffs_per_day ), @@ -139,10 +130,20 @@ class ClosedError(Exception): "normalize_public_workspace_display_settings", "normalize_key_vault_reminder_settings", "normalize_model_endpoint_identity_header_settings", - "normalize_retired_orchestration_settings", "normalize_mixed_source_derived_settings", ): namespace[name] = lambda _settings: None + settings_tree = ast.parse((APP_ROOT / "functions_settings.py").read_text(encoding="utf-8")) + retired = next( + node.value for node in settings_tree.body + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == "RETIRED_SETTING_KEYS" for target in node.targets + ) + ) + namespace["RETIRED_SETTING_KEYS"] = ast.literal_eval(retired) + namespace["normalize_retired_orchestration_settings"] = _production_function( + "functions_settings.py", "normalize_retired_orchestration_settings", namespace, + ) return _production_function("functions_settings.py", "update_settings", namespace) @@ -151,16 +152,13 @@ def test_version_is_at_least_the_draft_service_release(): def test_the_defaults_are_the_roadmap_defaults_and_the_safe_values(): - """20 workflows per user and an hourly floor; unset settings read as those defaults.""" + """20 workflows per user and the shared one-second floor.""" assert (CHAT_ORCHESTRATION_MAX_WORKFLOWS_DEFAULT, CHAT_ORCHESTRATION_MAX_WORKFLOWS_MIN, CHAT_ORCHESTRATION_MAX_WORKFLOWS_MAX) == (20, 1, 100) - assert (CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_DEFAULT, CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_MIN, - CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_MAX) == (3600, 60, 86400) assert get_chat_orchestration_max_workflows_per_user({}) == 20 - assert get_chat_orchestration_min_workflow_interval_seconds({}) == 3600 - assert get_orchestration_workflow_min_interval_seconds({}) == 3600 + assert get_orchestration_workflow_min_interval_seconds({}) == 1 - # get_settings seeds both keys from the same constants. + # get_settings no longer seeds the retired field. tree = ast.parse((APP_ROOT / "functions_settings.py").read_text(encoding="utf-8")) get_settings = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "get_settings") seeded = { @@ -171,7 +169,6 @@ def test_the_defaults_are_the_roadmap_defaults_and_the_safe_values(): } assert seeded == { CAP: "CHAT_ORCHESTRATION_MAX_WORKFLOWS_DEFAULT", - FLOOR: "CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_DEFAULT", } @@ -184,12 +181,11 @@ def test_the_defaults_are_the_roadmap_defaults_and_the_safe_values(): "Workflows Created From Chat Per User must be a whole number from 1 to 100.", ), ( - validate_chat_orchestration_min_workflow_interval_seconds, - (60, 3600, 86400, "60", " 7200 ", "86400"), - (None, "", "1h", "3600.0", "\u0663\u0666\u0660\u0660", -60, 0, 59, 86401, 3600.0, True, [], {}), - "chat_orchestration_workflow_interval_invalid", - "Minimum Schedule Interval For Workflows Created From Chat must be a whole number of seconds " - "from 60 to 86,400.", + validate_workflow_min_schedule_interval_seconds, + (1, 60, 3600, 86400, "1", " 7200 ", "86400"), + (None, "", "1h", "3600.0", "\u0663\u0666\u0660\u0660", -60, 0, 86401, 3600.0, True, [], {}), + "workflow_schedule_interval_limit_invalid", + "Workflow Minimum Schedule Interval must be a whole number of seconds from 1 to 86,400.", ), ]) def test_values_are_validated_not_clamped(validator, accepted, rejected, code, message): @@ -206,14 +202,12 @@ def test_values_are_validated_not_clamped(validator, accepted, rejected, code, m def test_the_getters_fail_closed_on_a_corrupt_or_missing_settings_document(): """A stored value outside its range is a server fault, never silently corrected at use.""" assert get_chat_orchestration_max_workflows_per_user({CAP: 5}) == 5 - assert get_chat_orchestration_min_workflow_interval_seconds({FLOOR: "600"}) == 600 + assert get_orchestration_workflow_min_interval_seconds({GENERAL_FLOOR: "600", FLOOR: 86400}) == 600 for getter, key, value, code in ( (get_chat_orchestration_max_workflows_per_user, CAP, 0, "chat_orchestration_workflow_limit_invalid"), (get_chat_orchestration_max_workflows_per_user, CAP, None, "chat_orchestration_workflow_limit_invalid"), - (get_chat_orchestration_min_workflow_interval_seconds, FLOOR, 30, - "chat_orchestration_workflow_interval_invalid"), - (get_orchestration_workflow_min_interval_seconds, FLOOR, True, - "chat_orchestration_workflow_interval_invalid"), + (get_orchestration_workflow_min_interval_seconds, GENERAL_FLOOR, True, + "workflow_schedule_interval_limit_invalid"), (get_orchestration_workflow_min_interval_seconds, GENERAL_FLOOR, 0, "workflow_schedule_interval_limit_invalid"), ): @@ -222,7 +216,7 @@ def test_the_getters_fail_closed_on_a_corrupt_or_missing_settings_document(): assert raised.value.code == code for getter, code in ( (get_chat_orchestration_max_workflows_per_user, "chat_orchestration_workflow_limit_unavailable"), - (get_chat_orchestration_min_workflow_interval_seconds, "chat_orchestration_workflow_interval_unavailable"), + (get_orchestration_workflow_min_interval_seconds, "workflow_schedule_interval_limit_unavailable"), ): with pytest.raises(WorkflowLoopLimitError) as raised: getter(["not", "a", "settings", "document"]) @@ -230,14 +224,15 @@ def test_the_getters_fail_closed_on_a_corrupt_or_missing_settings_document(): @pytest.mark.parametrize("settings, floor", [ - ({}, 3600), - ({GENERAL_FLOOR: 300}, 3600), - ({FLOOR: 60}, 60), + ({}, 1), + ({GENERAL_FLOOR: 300}, 300), + ({FLOOR: 60}, 1), ({FLOOR: 60, GENERAL_FLOOR: 7200}, 7200), ({FLOOR: 900, GENERAL_FLOOR: 900}, 900), - ({FLOOR: 86400, GENERAL_FLOOR: 1}, 86400), + ({FLOOR: 86400, GENERAL_FLOOR: 1}, 1), + ({FLOOR: "corrupt", GENERAL_FLOOR: 60}, 60), ]) -def test_the_larger_of_both_floors_applies(settings, floor): +def test_only_the_general_floor_applies(settings, floor): assert get_orchestration_workflow_min_interval_seconds(settings) == floor @@ -259,8 +254,8 @@ def test_the_cadence_check_refuses_only_intervals_below_the_floor(schedule, floo assert isinstance(raised.value, WorkflowPublicValidationError) assert raised.value.code == "cadence_below_minimum" assert re.fullmatch( - r"Workflows created from chat cannot run this often\. Choose an interval of at least [0-9]+ \w+, " - r"or a daily, weekly or monthly schedule\.", + r"This schedule runs more often than the administrator allows\. " + r"Choose an interval of at least [0-9]+ \w+\.", raised.value.public_message, ) @@ -268,12 +263,12 @@ def test_the_cadence_check_refuses_only_intervals_below_the_floor(schedule, floo @pytest.mark.parametrize("schedule", CALENDARS, ids=[schedule["frequency"] for schedule in CALENDARS]) def test_calendar_schedules_always_pass_the_cadence_check(schedule): """Calendar schedules repeat at most daily, so even the largest floor admits them.""" - assert enforce_orchestration_workflow_cadence(schedule, CHAT_ORCHESTRATION_MIN_WORKFLOW_INTERVAL_MAX) is schedule + checked = enforce_orchestration_workflow_cadence(schedule, 86400) + assert checked is schedule @pytest.mark.parametrize("key, default, minimum, maximum", [ (CAP, 20, 1, 100), - (FLOOR, 3600, 60, 86400), ]) def test_the_v2_admin_fields_are_gated_limits_that_refuse_invalid_values(key, default, minimum, maximum): """The V2 schema offers each limit only with Chat Orchestration on, and refuses bad input.""" @@ -304,14 +299,15 @@ def test_the_classic_form_clamps_like_the_rest_of_its_pane(): def read(form, settings=None): values = normalize(MultiDict(form), settings or {}) - return values[CAP], values[FLOOR] + assert FLOOR not in values + return values[CAP] - assert read([]) == (20, 3600) - assert read([], {CAP: 7, FLOOR: 900}) == (7, 900) - assert read([(CAP, "12"), (FLOOR, "7200")]) == (12, 7200) - assert read([(CAP, "0"), (FLOOR, "30")]) == (1, 60) - assert read([(CAP, "500"), (FLOOR, "999999")]) == (100, 86400) - assert read([(CAP, SECRET), (FLOOR, SECRET)], {CAP: 7, FLOOR: 900}) == (7, 900) + assert read([]) == 20 + assert read([], {CAP: 7, FLOOR: 900}) == 7 + assert read([(CAP, "12"), (FLOOR, "7200")]) == 12 + assert read([(CAP, "0"), (FLOOR, "30")]) == 1 + assert read([(CAP, "500"), (FLOOR, "999999")]) == 100 + assert read([(CAP, SECRET), (FLOOR, SECRET)], {CAP: 7, FLOOR: 900}) == 7 def test_the_classic_pane_bounds_match_the_v2_schema(): @@ -324,7 +320,9 @@ def test_the_classic_pane_bounds_match_the_v2_schema(): if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "_clamped" and node.args and isinstance(node.args[0], ast.Constant) } - for key in (CAP, FLOOR): + assert FLOOR not in pane and FLOOR not in clamps + assert FIELDS.get_field_definition(FLOOR) is None + for key in (CAP,): field = FIELDS.get_field_definition(key) assert clamps[key] == (field["default"], field["min"], field["max"]) tag = re.search(rf']*\bname="{key}"[^>]*>', pane, re.S) @@ -338,9 +336,10 @@ def test_the_settings_writer_validates_before_storing_and_keeps_absent_values(): storage = FakeCosmos() storage.document[CAP] = 15 storage.document[FLOOR] = 1800 + storage.document[GENERAL_FLOOR] = 1 writer = _settings_writer(storage) - for update in ({CAP: 0}, {CAP: "101"}, {FLOOR: 59}, {FLOOR: True}, {CAP: 10, FLOOR: SECRET}): + for update in ({CAP: 0}, {CAP: "101"}, {GENERAL_FLOOR: 0}, {GENERAL_FLOOR: True}, {CAP: 10, GENERAL_FLOOR: SECRET}): with pytest.raises(WorkflowLoopLimitError) as raised: writer(dict(update)) assert SECRET not in raised.value.public_message @@ -349,18 +348,52 @@ def test_the_settings_writer_validates_before_storing_and_keeps_absent_values(): embedding = ModuleType("functions_embedding_compatibility") embedding.embedding_settings_write_guard = lambda *_args, **_kwargs: nullcontext() with patch.dict(sys.modules, {"functions_embedding_compatibility": embedding}): - assert writer({"allow_user_workflows": True}) - assert (storage.document[CAP], storage.document[FLOOR]) == (15, 1800) + saved = writer({"allow_user_workflows": True}) + assert saved + assert (storage.document[CAP], storage.document[GENERAL_FLOOR]) == (15, 1) + assert FLOOR not in storage.document update = {CAP: "25", FLOOR: " 7200 "} - assert writer(update) - assert (storage.document[CAP], storage.document[FLOOR]) == (25, 7200) + saved = writer(update) + assert saved + assert (storage.document[CAP], storage.document[GENERAL_FLOOR]) == (25, 1) + assert FLOOR not in storage.document assert update == {CAP: "25", FLOOR: " 7200 "} -def test_the_admin_docs_describe_both_limits(): - """The Chat Orchestration admin page documents each limit with its default and key.""" +def test_the_admin_docs_describe_the_count_cap_and_shared_minimum(): + """The current admin table contains no separate chat schedule policy.""" page = (ROOT / "docs" / "admin" / "orchestration.md").read_text(encoding="utf-8") - for key, default in ((CAP, "20"), (FLOOR, "3600")): + assert not any(line.startswith("|") and f"`{FLOOR}`" in line for line in page.splitlines()) + assert GENERAL_FLOOR in page + for key, default in ((CAP, "20"),): row = next((line for line in page.splitlines() if line.startswith("|") and f"`{key}`" in line), None) assert row, f"docs/admin/orchestration.md has no settings row for {key}" assert f"| {default} |" in row + + +@pytest.mark.parametrize("optimized", [False, True]) +def test_real_settings_load_discards_legacy_floor_without_changing_shared_policy(optimized): + probe = """ +from test_support.offline_bootstrap import offline_app_imports +with offline_app_imports(): + import functions_settings as settings + storage = settings.cosmos_settings_container + storage.upsert_item({ + 'id': 'app_settings', + 'workflow_min_schedule_interval_seconds': 1, + 'chat_orchestration_min_workflow_interval_seconds': 86400, + }) + loaded = settings.get_settings(use_cosmos=True) + if loaded['workflow_min_schedule_interval_seconds'] != 1: + raise AssertionError('Legacy policy changed the shared minimum.') + if 'chat_orchestration_min_workflow_interval_seconds' in loaded: + raise AssertionError('Retired policy survived settings load.') + stored = storage.read_item('app_settings', 'app_settings') + if 'chat_orchestration_min_workflow_interval_seconds' in stored: + raise AssertionError('Retired policy was not removed from storage.') +""" + result = subprocess.run( + [sys.executable, *(["-O"] if optimized else []), "-c", probe], + cwd=ROOT, capture_output=True, text=True, timeout=90, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/functional_tests/test_workflow_plan_replay_routes.py b/functional_tests/test_workflow_plan_replay_routes.py index 2c4213243..22a859aa7 100644 --- a/functional_tests/test_workflow_plan_replay_routes.py +++ b/functional_tests/test_workflow_plan_replay_routes.py @@ -2,8 +2,9 @@ # test_workflow_plan_replay_routes.py """ Functional test for the saved-plan replay routes: preview and save a completed chat plan. -Version: 0.261.308 +Version: 0.261.317 Implemented in: 0.261.308 +Shared schedule policy coverage implemented in: 0.261.317 This test ensures that only the creator, in a private conversation they own, can preview or save a completed chat orchestration plan as a personal workflow that repeats it. The preview discloses @@ -525,13 +526,29 @@ def test_personal_workflows_off_blocks_both_routes(h): require(status == 400 and stored(h) == [], f"{status} {body}") -def test_a_schedule_faster_than_the_chat_minimum_is_refused(h): +def test_a_schedule_faster_than_the_shared_minimum_is_refused(h): seed_run(h) login(h) - _refused(save_previewed(h, schedule={"unit": "minutes", "value": 5}), 422, "cadence_below_minimum") + h.state.settings["workflow_min_schedule_interval_seconds"] = 301 + _refused(save_previewed(h, schedule={"unit": "minutes", "value": 5}), 422, "invalid_workflow_settings") require(stored(h) == [], "A too-frequent schedule creates nothing.") +def test_a_replay_can_run_every_minute_under_the_shared_policy(h): + seed_run(h) + login(h) + h.state.settings["workflow_min_schedule_interval_seconds"] = 60 + h.state.settings["chat_orchestration_min_workflow_interval_seconds"] = 86400 + status, body = save_previewed(h, schedule={"unit": "minutes", "value": 1}) + require(status == 201, f"{status} {body}") + require(stored(h)[0]["schedule"] == {"unit": "minutes", "value": 1}, "Minute cadence is persisted.") + h.state.settings["workflow_min_schedule_interval_seconds"] = 3600 + edited = deepcopy(body["workflow"]) + edited["name"] = "Renamed minute replay" + saved = h.personal.save_personal_workflow(OWNER, edited, actor_user_id=OWNER) + require(saved["schedule"] == {"unit": "minutes", "value": 1}, "An unchanged schedule stays editable.") + + def test_a_refused_step_is_named_and_nothing_is_created(h): seed_run(h, plan=_plan( _step("deep_research", 1, title=HOSTILE_TITLE, query="news"), @@ -633,10 +650,10 @@ def test_the_frozen_plan_stays_read_only_through_the_ordinary_save(h): require(saved["tasks"][0]["type"] == h.replay.PLAN_REPLAY_TASK_TYPE, "The task keeps its type.") require(saved["tasks"][0]["plan_replay"]["plan_sha256"] == original["plan_sha256"], "The plan is unchanged.") - # The chat-workflow cadence floor applies every time the schedule is saved, not only at creation. + h.state.settings["workflow_min_schedule_interval_seconds"] = 301 faster = json.loads(json.dumps(saved)) faster["schedule"] = {"unit": "minutes", "value": 5} - cadence_error = importlib.import_module("functions_workflow_definitions").WorkflowCadenceError + cadence_error = importlib.import_module("functions_workflow_definitions").WorkflowPublicValidationError with pytest.raises(cadence_error): h.personal.save_personal_workflow(OWNER, faster, actor_user_id=OWNER) diff --git a/ui_tests/test_admin_orchestration_workflow_proposals.py b/ui_tests/test_admin_orchestration_workflow_proposals.py index c922d62fd..fd873c80e 100644 --- a/ui_tests/test_admin_orchestration_workflow_proposals.py +++ b/ui_tests/test_admin_orchestration_workflow_proposals.py @@ -1,8 +1,9 @@ # test_admin_orchestration_workflow_proposals.py """ UI coverage for the switch that lets chat orchestration propose workflows, in both admin surfaces. -Version: 0.261.207 +Version: 0.261.317 Implemented in: 0.261.207 +Chat-only minimum retirement coverage implemented in: 0.261.317 The V2 page shows Propose Workflows From Chat off by default, only while Chat Orchestration and personal workflows are both on, and saves it as a partial update through the production field @@ -82,6 +83,7 @@ def test_v2_switch_defaults_off_and_saves_as_a_partial_update(orchestration_admi expect(_switch(page, LABEL)).not_to_be_checked() expect(page.get_by_text("Off by default, because an approved proposal", exact=False)).to_be_visible() expect(page.get_by_role("checkbox", name="Propose workflows", exact=True)).to_be_visible() + expect(page.get_by_label("Minimum Schedule Interval For Workflows Created From Chat (seconds)", exact=True)).to_have_count(0) _set_switch(page, LABEL, True) _save(page) @@ -156,6 +158,7 @@ def test_classic_pane_switch_submits_a_normal_form_value(page, width, enabled): page.set_viewport_size({"width": width, "height": 900}) _render_classic_pane(page, {"enable_chat_orchestration": True, "allow_user_workflows": True, KEY: enabled}) checkbox = page.get_by_label(LABEL, exact=True) + expect(page.locator('[name="chat_orchestration_min_workflow_interval_seconds"]')).to_have_count(0) expect(checkbox).to_be_checked(checked=enabled) expect(checkbox).to_have_attribute("aria-describedby", HELP_ID) expect(page.locator(f"#{HELP_ID}")).to_be_visible() diff --git a/ui_tests/test_v2_orchestration_workflow_proposal_card.py b/ui_tests/test_v2_orchestration_workflow_proposal_card.py index 4b31e45ab..ecc8865dc 100644 --- a/ui_tests/test_v2_orchestration_workflow_proposal_card.py +++ b/ui_tests/test_v2_orchestration_workflow_proposal_card.py @@ -1,8 +1,9 @@ # test_v2_orchestration_workflow_proposal_card.py """ Real-component browser tests for the workflow proposal card under an orchestration answer. -Version: 0.261.315 +Version: 0.261.317 Implemented in: 0.261.207; merge task wording added in 0.261.241; merge kinds in 0.261.242; Word in 0.261.243; PowerPoint in 0.261.244 +Shared schedule policy coverage implemented in: 0.261.317; merge task wording added in 0.261.241; merge kinds in 0.261.242; Word in 0.261.243; PowerPoint in 0.261.244 Next and last run on a created card: 0.261.251 (microsoft/simplechat#1546) Microsoft 365 links stay in V2 Settings and V2 Approvals: 0.261.302 Refs: microsoft/simplechat#1547, microsoft/simplechat#1619 @@ -240,7 +241,7 @@ def agent_proposal(): return value -def editor_options(): +def editor_options(minimum=3600): return { "definition_version": 2, "supported_definition_versions": [1, 2, 3], @@ -249,7 +250,7 @@ def editor_options(): "max_nodes": 256, "max_depth": 4, "max_predicate_nodes": 100, "max_predicate_depth": 8, "max_executions": 5000, "deadline_seconds": 86400, }, - "schedule": functions_workflow_schedules.build_workflow_schedule_editor_options(min_interval_seconds=3600), + "schedule": functions_workflow_schedules.build_workflow_schedule_editor_options(min_interval_seconds=minimum), "can_manage": True, "max_tasks": 5, "agents": [], @@ -271,6 +272,7 @@ def __init__(self, assets, record=None): self.expected_errors = set() self.status_error = None self.accept_errors = [] + self.minimum_interval_seconds = 3600 def error(self, route, status, message, code): self.expected_errors.add((urlsplit(route.request.url).path, status)) @@ -327,7 +329,7 @@ def handle(self, route): route.fulfill(json={"proposal_id": PROPOSAL_ID, "state": "denied"}) return if path == EDITOR_OPTIONS_PATH and request.method == "GET": - route.fulfill(json=editor_options()) + route.fulfill(json=editor_options(self.minimum_interval_seconds)) return if path == "/api/documents" and request.method == "GET": route.fulfill(json={"documents": [], "total_count": 0, "page": 1, "page_size": 10}) diff --git a/ui_tests/test_v2_workflow_ask_ai_proposal.py b/ui_tests/test_v2_workflow_ask_ai_proposal.py index d391ac90d..34788c64d 100644 --- a/ui_tests/test_v2_workflow_ask_ai_proposal.py +++ b/ui_tests/test_v2_workflow_ask_ai_proposal.py @@ -1,9 +1,10 @@ # test_v2_workflow_ask_ai_proposal.py """ Real-component browser test for Ask AI on a workflow proposal's draft (Phase 3c). -Version: 0.261.271 +Version: 0.261.317 The Workflows workbench and the routed editor page replace the list rows and the editor dialog: 0.261.271 Implemented in: 0.261.213 +Shared schedule policy and save-error coverage implemented in: 0.261.317 Refs: microsoft/simplechat#1548 Phase 4's proposal card opens the workflow editor on the proposal's draft through Edit. Ask AI @@ -81,7 +82,8 @@ def record_error(url, status): # A proposal draft has no saved workflow to read, and these tests send no # references. stub = AssistStub( - user_id="proposal-tester", record_error=record_error, options=editor_options, read_base=None, resolve=None, + user_id="proposal-tester", record_error=record_error, + options=lambda: editor_options(api.minimum_interval_seconds), read_base=None, resolve=None, ) # Registered after the harness's catch-all route, so it answers the assist route first. page.route(ASSIST_ROUTE, stub.handle) @@ -179,6 +181,102 @@ def test_a_proposal_draft_sends_no_base_and_saves_through_review(ask_card_ui): expect(card(page).get_by_role("status")).to_have_text("Created, paused") +@pytest.mark.parametrize("minimum", [1, 60]) +def test_ask_ai_preserves_every_minute_through_review_and_proposal_save(ask_card_ui, minimum): + page, api, stub = ask_card_ui + api.minimum_interval_seconds = minimum + api.draft["schedule"] = {"unit": "hours", "value": 1} + api.draft["trigger_type"] = "interval" + stub.queue(stub.changed( + {"op": "set_schedule_interval", "unit": "minutes", "value": 1}, + text="The workflow now runs every minute.", + )) + mount(page, api) + enable_ask_ai(page) + dialog, _ = open_editor(page, card(page)) + dialog.get_by_role("button", name="Ask AI", exact=True).click() + panel = side_panel(dialog) + box = panel.get_by_role("textbox", name="Message Ask AI", exact=True) + box.fill("Run every minute.") + box.press("Enter") + stub.wait_for_requests(page, 1) + expect(panel.locator("[data-workflow-assist-card]")).to_have_attribute("data-state", "applied") + dialog.get_by_role("button", name="Save workflow", exact=True).click() + review = panel.get_by_role("region", name="Review before saving", exact=True) + expect(review).to_be_visible() + assert not api.writes() + review.get_by_role("button", name="Confirm and save", exact=True).click() + expect(dialog).to_have_count(0) + writes = api.writes() + assert len(writes) == 1 + assert writes[0]["body"]["workflow"]["schedule"] == {"unit": "minutes", "value": 1} + expect(card(page).get_by_role("status")).to_have_text("Created, paused") + + +@pytest.mark.parametrize("width", [1440, 390]) +def test_repeated_save_errors_reveal_and_focus_message_without_losing_ai_edits(ask_card_ui, width): + page, api, stub = ask_card_ui + page.set_viewport_size({"width": width, "height": 900}) + api.minimum_interval_seconds = 1 + api.draft["schedule"] = {"unit": "hours", "value": 1} + api.draft["trigger_type"] = "interval" + message = "This schedule runs more often than the administrator allows. Choose an interval of at least 2 minutes." + api.accept_errors.extend([(400, message, "invalid_workflow_settings")] * 2) + stub.queue(stub.changed({"op": "set_schedule_interval", "unit": "minutes", "value": 1})) + mount(page, api) + enable_ask_ai(page) + dialog, name = open_editor(page, card(page)) + dialog.get_by_role("button", name="Ask AI", exact=True).click() + panel = side_panel(dialog) + box = panel.get_by_role("textbox", name="Message Ask AI", exact=True) + box.fill("Run every minute.") + box.press("Enter") + stub.wait_for_requests(page, 1) + expect(panel.locator("[data-workflow-assist-card]")).to_have_attribute("data-state", "applied") + dialog.get_by_role("button", name="Save workflow", exact=True).click() + review = panel.get_by_role("region", name="Review before saving", exact=True) + review.get_by_role("button", name="Confirm and save", exact=True).click() + error = dialog.get_by_role("alert").filter(has_text=message) + expect(error).to_be_focused() + expect(error).to_be_in_viewport(ratio=1) + expect(name).to_have_value(NAME) + assert api.writes()[0]["body"]["workflow"]["schedule"] == {"unit": "minutes", "value": 1} + + dialog.get_by_role("button", name="Ask AI", exact=True).click() + box = panel.get_by_role("textbox", name="Message Ask AI", exact=True) + expect(box).to_be_focused() + dialog.get_by_role("button", name="Save workflow", exact=True).click() + review.get_by_role("button", name="Confirm and save", exact=True).click() + expect(error).to_be_focused() + expect(error).to_be_in_viewport(ratio=1) + assert len(api.writes()) == 2 + dialog.get_by_role("button", name="Save workflow", exact=True).click() + review.get_by_role("button", name="Confirm and save", exact=True).click() + expect(dialog).to_have_count(0) + assert api.writes()[-1]["body"]["workflow"]["schedule"] == {"unit": "minutes", "value": 1} + + +@pytest.mark.parametrize("width", [1440, 390]) +def test_below_minimum_is_explained_on_save_and_repeated_validation_reveals_it(card_ui, width): + page, api = card_ui + page.set_viewport_size({"width": width, "height": 900}) + api.minimum_interval_seconds = 61 + api.draft["trigger_type"] = "interval" + api.draft["schedule"] = {"unit": "minutes", "value": 1} + mount(page, api) + dialog, name = open_editor(page, card(page)) + message = "This schedule runs more often than the administrator allows. Choose an interval of at least 61 seconds." + error = dialog.get_by_role("alert").filter(has_text=message) + expect(error).to_have_count(0) + for _ in range(2): + dialog.get_by_role("heading", name="Alerts", exact=True).scroll_into_view_if_needed() + dialog.get_by_role("button", name="Save workflow", exact=True).click() + expect(error).to_be_focused() + expect(error).to_be_in_viewport(ratio=1) + expect(name).to_have_value(NAME) + assert not api.writes() + + def test_ask_ai_is_hidden_in_a_proposal_draft_when_the_assistant_is_off(ask_card_ui): page, api, stub = ask_card_ui mount(page, api)