diff --git a/application/single_app/config.py b/application/single_app/config.py index 1439ae67d..cbd13de68 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.233" +VERSION = "0.261.234" 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_analysis_access.py b/application/single_app/functions_analysis_access.py index 02573cafa..706f26784 100644 --- a/application/single_app/functions_analysis_access.py +++ b/application/single_app/functions_analysis_access.py @@ -1,5 +1,9 @@ # functions_analysis_access.py -"""Current source access checks for saved Analyze results.""" +"""Source checks for Analyze inputs, and the error raised when an analysis can't be used. + +Only reading a document as an input checks it. A saved or generated result takes its access +from the conversation, workflow or run that holds it, and keeps its sources as provenance. +""" from collections import defaultdict import math @@ -18,13 +22,123 @@ ANALYSIS_SOURCE_ACCESS_VERSION = "analysis-source-access-v1" +# Each code is classified by what its raise sites check, not by its name. A raise site whose +# code is shared with another family passes ``family=`` instead. Every code raised in +# application/single_app must be classified: functional_tests/ +# test_analysis_unavailable_message_families.py scans the raise sites and fails otherwise. +ANALYSIS_UNAVAILABLE_SOURCE = "source" +ANALYSIS_UNAVAILABLE_CONTAINER = "container" +ANALYSIS_UNAVAILABLE_SAVED_RESULT = "saved_result" + +# An input read found a source document it can't read now, or one that changed while it was read. +ANALYSIS_SOURCE_UNAVAILABLE_CODES = frozenset({ + "analysis_source_manifest_changed", + "analysis_source_snapshot_changed", + "analysis_source_unavailable", + # Loop document re-authorization passes its WorkflowLoopInputError code through. + "workflow_loop_input_unavailable", + "workflow_loop_scope_forbidden", + "workflow_loop_source_changed", +}) + +# The conversation, message, workflow or run that holds the result is gone or isn't the caller's. +ANALYSIS_CONTAINER_UNAVAILABLE_CODES = frozenset({ + "analysis_conversation_changed", + "analysis_conversation_deleted", + "analysis_conversation_unavailable", + "analysis_conversation_version_missing", + "analysis_message_masked", + "analysis_message_unavailable", + "analysis_retry_changed", + "analysis_retry_unavailable", + "analysis_workflow_unavailable", + "generated_artifact_source_unavailable", + "native_compute_owner_unavailable", +}) + +# The stored result, or an earlier result, manifest, receipt, or loop or repeat state it +# depends on, is missing, changed or invalid. +ANALYSIS_SAVED_RESULT_UNAVAILABLE_CODES = frozenset({ + "analysis_artifact_not_valid", + "analysis_artifact_unbound", + "analysis_lineage_invalid", + "analysis_native_identity_invalid", + "analysis_native_source_mismatch", + "analysis_producer_unavailable", + "analysis_result_unavailable", + "analysis_source_manifest_conflict", + "analysis_source_manifest_invalid", + "analysis_source_manifest_missing", + "generated_artifact_content_changed", + "generated_artifact_source_conflict", + "generated_artifact_source_unbound", + "generated_artifact_source_uncommitted", + "native_compute_identity_invalid", + "native_compute_producer_stopped", + "native_compute_producer_unavailable", + "native_compute_result_changed", + "native_compute_result_unavailable", + "workflow_body_export_invalid", + "workflow_body_export_scope_invalid", + "workflow_iteration_outcome_invalid", + "workflow_iteration_receipt_invalid", + "workflow_loop_identity_invalid", + "workflow_loop_item_changed", + "workflow_loop_item_invalid", + "workflow_loop_manifest_invalid", + "workflow_loop_manifest_unavailable", + "workflow_loop_source_receipt_invalid", + "workflow_repeat_admission_invalid", + "workflow_repeat_grant_invalid", + "workflow_repeat_head_invalid", + "workflow_repeat_identity_invalid", + "workflow_repeat_iteration_invalid", + "workflow_repeat_source_invalid", + "workflow_repeat_source_uncommitted", + "workflow_repeat_state_changed", + "workflow_repeat_state_invalid", + "workflow_repeat_state_uncommitted", + "workflow_repeat_transition_invalid", +}) + +ANALYSIS_UNAVAILABLE_MESSAGES = { + ANALYSIS_UNAVAILABLE_SOURCE: "A source document for this analysis is no longer available or has changed.", + ANALYSIS_UNAVAILABLE_CONTAINER: ( + "The conversation, workflow or run that holds this result no longer exists or isn't available to you." + ), + ANALYSIS_UNAVAILABLE_SAVED_RESULT: ( + "This saved result can't be used because something it depends on is missing or has changed." + ), +} + + +def analysis_unavailable_family(code): + """Return the message family for ``code``. + + An unclassified code gets the saved-result wording, which blames neither a source + document nor the caller's access. + """ + if isinstance(code, str): + if code in ANALYSIS_SOURCE_UNAVAILABLE_CODES: + return ANALYSIS_UNAVAILABLE_SOURCE + if code in ANALYSIS_CONTAINER_UNAVAILABLE_CODES: + return ANALYSIS_UNAVAILABLE_CONTAINER + return ANALYSIS_UNAVAILABLE_SAVED_RESULT + class AnalysisResultUnavailable(PermissionError): - """A saved analysis cannot be read under the caller's current access.""" + """An analysis input or saved result can't be used. - def __init__(self, code="analysis_source_unavailable"): + ``code`` names the check that failed. ``family`` picks the public wording: a source + document, the conversation, workflow or run that holds the result, or the saved result + itself. Pass ``family`` only when the code is shared with a different family. + """ + + def __init__(self, code="analysis_source_unavailable", *, family=None): self.code = code - super().__init__("This analysis is unavailable because its source access could not be confirmed.") + self.family = family if family in ANALYSIS_UNAVAILABLE_MESSAGES else analysis_unavailable_family(code) + self.public_message = ANALYSIS_UNAVAILABLE_MESSAGES[self.family] + super().__init__(self.public_message) def resolve_analysis_source_manifest(document_ids, user_id, *, resolver=None, **context): @@ -50,7 +164,8 @@ def resolve_analysis_source_manifest(document_ids, user_id, *, resolver=None, ** ): if strict_source_authority_enabled(): raise_source_authority_error(SourceAuthorityUnverifiedError()) - raise AnalysisResultUnavailable("analysis_source_manifest_invalid") + # The current source lookup itself failed: an input read, not a saved manifest. + raise AnalysisResultUnavailable("analysis_source_manifest_invalid", family=ANALYSIS_UNAVAILABLE_SOURCE) if strict_source_authority_enabled(): for source in resolved: if source.get("authorization_status") not in {"authorized", "unresolved"}: @@ -138,7 +253,7 @@ def build_analysis_access(sources=(), *, inherited=()): def authorize_analysis_sources(user_id, sources, *, require_snapshot=False, resolver=None): """Authorize every contributor; a saved permission is never sufficient.""" if not isinstance(user_id, str) or not user_id.strip(): - raise AnalysisResultUnavailable() + raise AnalysisResultUnavailable("analysis_source_unavailable") snapshots = analysis_source_snapshot(sources) if not snapshots: raise AnalysisResultUnavailable("analysis_source_manifest_missing") @@ -163,7 +278,7 @@ def authorize_analysis_sources(user_id, sources, *, require_snapshot=False, reso fresh, ) if comparison["authorization_failure_count"]: - raise AnalysisResultUnavailable() + raise AnalysisResultUnavailable("analysis_source_unavailable") fresh_by_id = {source["document_id"]: source for source in fresh} for source in original: current = fresh_by_id[source["document_id"]] diff --git a/application/single_app/functions_document_analysis_checkpoints.py b/application/single_app/functions_document_analysis_checkpoints.py index 6dd364d1d..85a1cf972 100644 --- a/application/single_app/functions_document_analysis_checkpoints.py +++ b/application/single_app/functions_document_analysis_checkpoints.py @@ -94,7 +94,7 @@ def validate_sources(self, sources=None): require_snapshot=True, ) if allowed is False: - raise AnalysisResultUnavailable() + raise AnalysisResultUnavailable('analysis_source_unavailable') def initialize(self, request, sources): assigned = analysis_source_snapshot(sources) diff --git a/application/single_app/functions_document_analysis_results.py b/application/single_app/functions_document_analysis_results.py index 565826b05..b05cd1341 100644 --- a/application/single_app/functions_document_analysis_results.py +++ b/application/single_app/functions_document_analysis_results.py @@ -8,7 +8,11 @@ import unicodedata from copy import deepcopy -from functions_analysis_access import AnalysisResultUnavailable, analysis_source_snapshot +from functions_analysis_access import ( + ANALYSIS_UNAVAILABLE_SOURCE, + AnalysisResultUnavailable, + analysis_source_snapshot, +) from functions_analysis_deliverables import apply_analysis_calculations from functions_tabular_transformations import normalize_tabular_transformation_spec @@ -198,7 +202,8 @@ def index_analysis_source_manifest(source_manifest, document_ids): if source['document_id'].strip() not in assigned_ids: continue if 'authorization_status' in source and source['authorization_status'] != 'authorized': - raise AnalysisResultUnavailable('analysis_source_manifest_invalid') + # An assigned document the current source lookup did not authorize can't be read now. + raise AnalysisResultUnavailable('analysis_source_manifest_invalid', family=ANALYSIS_UNAVAILABLE_SOURCE) snapshot = analysis_source_snapshot([source])[0] previous = snapshots.get(snapshot['document_id']) if previous is not None and previous != snapshot: diff --git a/application/single_app/functions_generated_artifact_sources.py b/application/single_app/functions_generated_artifact_sources.py index e1625153d..e31e6e030 100644 --- a/application/single_app/functions_generated_artifact_sources.py +++ b/application/single_app/functions_generated_artifact_sources.py @@ -28,7 +28,10 @@ _HISTORY_SOURCE_ERRORS = (PermissionError, LookupError, ValueError, AzureError, WorkflowResultStorageUnavailableError, RuntimeUnavailable, ScreeningError, OutputConflictError, OutputStorageError) -_UNAVAILABLE_HISTORY = "Saved workflow output is unavailable because current access could not be confirmed." +_UNAVAILABLE_HISTORY = ( + "Saved workflow output is unavailable because it or the workflow run it came from is missing, " + "has changed or can't be read right now." +) _ORCHESTRATION_UNAVAILABLE_HISTORY = "Generated file is unavailable because current access or publication could not be confirmed." _RETAINED_SOURCE_KINDS = frozenset({"workflow_saved_output", ORCHESTRATION_ARTIFACT_KIND}) _ORCHESTRATION_HISTORY_DENIAL_CODES = frozenset({ diff --git a/application/single_app/functions_saved_analysis.py b/application/single_app/functions_saved_analysis.py index bf880ac97..b5e28c932 100644 --- a/application/single_app/functions_saved_analysis.py +++ b/application/single_app/functions_saved_analysis.py @@ -39,7 +39,10 @@ MAX_ANALYSIS_PAGE_RECORDS = 100 MAX_ANALYSIS_PAGE_BYTES = 256 * 1024 MAX_ANALYSIS_DIAGNOSTIC_PAGE_BYTES = 65536 -UNAVAILABLE_ANALYSIS_MESSAGE = "This saved analysis is unavailable because its access could not be confirmed." +UNAVAILABLE_ANALYSIS_MESSAGE = ( + "This saved analysis is unavailable because it or something it depends on is missing, " + "has changed or can't be read right now." +) UNVERIFIED_WORKFLOW_OUTPUT_MESSAGE = "This saved task output is unavailable because it could not be read or verified." @@ -164,7 +167,7 @@ def _load_authorized_message(user_id, conversation_id, message_id): raise AnalysisResultUnavailable("analysis_conversation_deleted") message = get_collaboration_message(message_id) if message.get("conversation_id") != conversation_id or message.get("id") != message_id: - raise AnalysisResultUnavailable() + raise AnalysisResultUnavailable("analysis_message_unavailable") return message @@ -199,7 +202,7 @@ def _load_authorized_workflow(user_id, binding): or not item or item.get("workflow_id") != workflow["id"] or item.get("run_id") != binding["run_id"] or item.get("task_id") != binding["task_id"] ): - raise AnalysisResultUnavailable() + raise AnalysisResultUnavailable("analysis_workflow_unavailable") if binding.get("execution_id") and any( item.get(key) != binding.get(key) for key in ("node_id", "execution_id", "iteration_path") ): diff --git a/application/single_app/functions_workflow_artifacts.py b/application/single_app/functions_workflow_artifacts.py index bf2af6a1d..6bf9c89da 100644 --- a/application/single_app/functions_workflow_artifacts.py +++ b/application/single_app/functions_workflow_artifacts.py @@ -7,7 +7,7 @@ from azure.core.exceptions import ResourceNotFoundError from azure.cosmos.exceptions import CosmosResourceNotFoundError -from functions_analysis_access import AnalysisResultUnavailable +from functions_analysis_access import ANALYSIS_UNAVAILABLE_SAVED_RESULT, AnalysisResultUnavailable from functions_appinsights import log_event from functions_generated_artifact_sources import generated_chat_artifact_address from functions_generated_file_exports import GeneratedFileExportRequest, build_generated_file_export @@ -180,7 +180,10 @@ def load_workflow_artifact_binding(user_id, value, *, require_ready=True, for_pu "[SIMPLE_CHAT] Saved-output artifact source unavailable", {"exception_type": type(exc).__name__}, ) - raise AnalysisResultUnavailable("generated_artifact_source_unavailable") from exc + # The saved output's run records could not be loaded; the scope checks raise their own error. + raise AnalysisResultUnavailable( + "generated_artifact_source_unavailable", family=ANALYSIS_UNAVAILABLE_SAVED_RESULT, + ) from exc def _load_workflow_artifact_binding(user_id, value, *, require_ready, for_publication): diff --git a/application/single_app/functions_workflow_flow_runner.py b/application/single_app/functions_workflow_flow_runner.py index 18a2224cd..0eb4d46ae 100644 --- a/application/single_app/functions_workflow_flow_runner.py +++ b/application/single_app/functions_workflow_flow_runner.py @@ -5,7 +5,12 @@ from copy import deepcopy from functions_workflow_bindings import WorkflowInputError -from functions_analysis_access import AnalysisResultUnavailable +from functions_analysis_access import ( + ANALYSIS_UNAVAILABLE_CONTAINER, + ANALYSIS_UNAVAILABLE_SAVED_RESULT, + ANALYSIS_UNAVAILABLE_SOURCE, + AnalysisResultUnavailable, +) from functions_workflow_definitions import workflow_output_kind_matches from functions_workflow_flow import MISSING, compile_workflow_flow, evaluate_predicate from functions_workflow_identity import canonical_digest, workflow_execution_id, workflow_node_identity @@ -17,6 +22,18 @@ ) +# Pause reasons for a loop item that could not be read, by AnalysisResultUnavailable.family. +LOOP_ITEM_UNAVAILABLE_REASONS = { + ANALYSIS_UNAVAILABLE_SOURCE: "The current loop item's original source is no longer available.", + ANALYSIS_UNAVAILABLE_CONTAINER: ( + "The conversation, workflow or run that holds the current loop item's input is no longer available." + ), + ANALYSIS_UNAVAILABLE_SAVED_RESULT: ( + "The current loop item's saved input can't be used because something it depends on is missing or has changed." + ), +} + + class WorkflowFlowRunner: def __init__(self, workflow, run_id, execution, task_results, *, actor_user_id, settings=None, load_output=load_workflow_node_input, persist=persist_workflow_task_result): @@ -457,9 +474,12 @@ def _for_each(self, node, region_id): current = self.execution.store.journal_read("iteration", key) if current["payload"].get("state") not in {"running", "completed", "skipped"}: item_state = "failed" - except AnalysisResultUnavailable: + except AnalysisResultUnavailable as exc: self.execution.pause_input( - "The current loop item's original source is no longer available.", code="workflow_loop_source_unavailable", + LOOP_ITEM_UNAVAILABLE_REASONS.get( + getattr(exc, "family", None), LOOP_ITEM_UNAVAILABLE_REASONS[ANALYSIS_UNAVAILABLE_SOURCE], + ), + code="workflow_loop_source_unavailable", ) except WorkflowInputError: exports, item_state = {}, "failed" diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index 27739c9d4..0fbc5e8da 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -11066,10 +11066,18 @@ def dispatch_task(): level=logging.WARNING, exceptionTraceback=True, ) + # Keyed by AnalysisResultUnavailable.family (functions_analysis_access); a source is the default. + unavailable_error = { + 'container': 'A conversation, workflow or run that this task needs is no longer available.', + 'saved_result': ( + "A saved result this task needs can't be used because something it depends on " + 'is missing or has changed.' + ), + }.get(getattr(safe_error, 'family', None), 'A required source is no longer available for this workflow run.') task_error = ( str(safe_error) if isinstance(safe_error, (WorkflowContextBudgetError, WorkflowResultNotReadyError)) else safe_error.public_message if isinstance(safe_error, WorkflowInputError) - else 'A required source is no longer available for this workflow run.' if isinstance(safe_error, AnalysisResultUnavailable) + else unavailable_error if isinstance(safe_error, AnalysisResultUnavailable) else { 'runner': ( 'The task runner is unavailable, disabled, or no longer authorized. ' @@ -11249,10 +11257,20 @@ def save_analysis_section(bound_workflow, bound_run, bound_task, section, **_kwa except AnalysisResultUnavailable as exc: # Raised when the task's own analysis could not confirm the sources it read # while it ran, or its result provenance is malformed. Nothing was saved. - message = ( + # The error's family (functions_analysis_access) picks the wording; a source is the default. + message = { + 'container': ( + 'The analysis result was not saved because the conversation, workflow or run that ' + 'holds it is no longer available. Dependent tasks were not run.' + ), + 'saved_result': ( + 'The analysis result was not saved because something it depends on is missing or ' + 'has changed. Dependent tasks were not run.' + ), + }.get(getattr(exc, 'family', None), ( 'The analysis result was not saved because a document it read changed or became ' 'unavailable while it ran. Dependent tasks were not run.' - ) + )) _save_workflow_task_run_item( workflow, run_id, task, 'failed', attempt_count=attempt_count, error=message, created_at=created_at, runner_audit=runner_audit, diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index a7ed73f29..f7e3ca129 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -420,7 +420,12 @@ raise_if_workflow_context_blocked, workflow_context_budget_scope, ) -from functions_analysis_access import AnalysisResultUnavailable, resolve_analysis_source_manifest +from functions_analysis_access import ( + ANALYSIS_UNAVAILABLE_CONTAINER, + ANALYSIS_UNAVAILABLE_SAVED_RESULT, + AnalysisResultUnavailable, + resolve_analysis_source_manifest, +) from azure.core.exceptions import AzureError from functions_workflow_result_store import AnalysisWorkUnitConflictError, WorkflowResultStorageUnavailableError from functions_workflow_results import WorkflowResultNotReadyError @@ -15875,7 +15880,10 @@ def execute_saved_analysis_chat_request(data, publish_background_event=None, can extra={'error_type': type(exc).__name__}, level=logging.WARNING) message = 'The saved analysis could not be explained. Its saved data is unchanged.' status = 503 - if isinstance(exc, PermissionError): + if isinstance(exc, AnalysisResultUnavailable): + message = exc.public_message + status = 403 + elif isinstance(exc, PermissionError): message = 'This analysis is unavailable because access to its conversation or saved result could not be confirmed.' status = 403 elif isinstance(exc, (WorkflowContextBudgetError, WorkflowResultNotReadyError, SavedAnalysisFollowupUnsupported)): @@ -16754,7 +16762,9 @@ def execute_document_action_chat_request( ) return { 'error': ( - 'The analysis could not be saved because this conversation or a selected document is no longer available.' + 'The analysis could not be saved because something it depends on is missing or has changed.' + if isinstance(exc, AnalysisResultUnavailable) and exc.family == ANALYSIS_UNAVAILABLE_SAVED_RESULT + else 'The analysis could not be saved because this conversation or a selected document is no longer available.' if isinstance(exc, PermissionError) else 'The analysis completed, but its final data could not be saved.' ), @@ -16852,7 +16862,18 @@ def execute_document_action_chat_request( detail=f"phase={getattr(exc, 'phase', 'finalization')}", ) if isinstance(exc, AnalysisResultUnavailable): - return {'error': 'This analysis conversation is unavailable.', 'conversation_id': conversation_id}, 403 + return { + 'error': { + ANALYSIS_UNAVAILABLE_CONTAINER: 'This analysis conversation is unavailable.', + ANALYSIS_UNAVAILABLE_SAVED_RESULT: ( + 'The analysis could not be saved because something it depends on is missing or has changed.' + ), + }.get( + exc.family, + 'The analysis could not be saved because a selected document is no longer available or has changed.', + ), + 'conversation_id': conversation_id, + }, 403 if isinstance(exc, ScreeningError): return { 'error': exc.public_message, 'error_code': exc.code, 'conversation_id': conversation_id, diff --git a/application/single_app/route_backend_workflows.py b/application/single_app/route_backend_workflows.py index 5787da21b..bd90577e0 100644 --- a/application/single_app/route_backend_workflows.py +++ b/application/single_app/route_backend_workflows.py @@ -334,7 +334,10 @@ def _workflow_runtime_response(workflow_id, run_id, *, group=False, action=None) return jsonify({'runtime': runtime, 'can_decide': can_decide}) except WorkflowRuntimeConflict as exc: return jsonify({'error': exc.public_message, 'code': exc.code}), 409 - except PermissionError: + except PermissionError as exc: + if isinstance(exc, AnalysisResultUnavailable): + # A saved record behind the progress view failed its own check, not the caller's access. + return jsonify({'error': f'Workflow progress is unavailable. {exc.public_message}'}), 403 return jsonify({'error': 'Workflow progress is unavailable because current access could not be confirmed.'}), 403 except (LookupError, CosmosResourceNotFoundError): return jsonify({'error': 'Durable workflow run not found.'}), 404 diff --git a/docs/explanation/features/UPLOAD_ONLY_CONTENT_SCREENING.md b/docs/explanation/features/UPLOAD_ONLY_CONTENT_SCREENING.md index fa01bc364..26f4396b2 100644 --- a/docs/explanation/features/UPLOAD_ONLY_CONTENT_SCREENING.md +++ b/docs/explanation/features/UPLOAD_ONLY_CONTENT_SCREENING.md @@ -6,7 +6,7 @@ Content screening checks a document when it enters a workspace, and then trusts Earlier versions also re-checked every result produced from a screened document against that document, both when the result was saved and each time it was read. These results included saved Analyze results, orchestration and workflow outputs, generated files, chat replies and their citations, history and exports. A single deleted, re-uploaded or re-screened document, an access change, or even a failed lookup could hide a result that had already been produced. This happened even when screening was off. Chat history replaced a whole AI reply with "Source content is unavailable pending document screening and review." Saved Analyze results and orchestration files were withheld. -**Implemented in version: 0.261.232.** This version completes a stack of changes that began in 0.261.230. Application version tracking remains in `application\single_app\config.py`. +**Implemented in version: 0.261.232.** This version completes a stack of changes that began in 0.261.230. Version 0.261.234 rewords the errors that saved results raise (see [Public messages](#public-messages)). Application version tracking remains in `application\single_app\config.py`. **Related issue:** [#1621](https://github.com/microsoft/simplechat/issues/1621). Layer 1 also fixes [#1613](https://github.com/microsoft/simplechat/issues/1613). @@ -94,11 +94,16 @@ The same rule applies when a chat request or an orchestration run publishes its | Situation | Message | | --- | --- | -| A saved Analyze follow-up cannot confirm the conversation or saved result | "This analysis is unavailable because access to its conversation or saved result could not be confirmed." | +| A saved Analyze follow-up's saved result, or something it depends on, is missing or changed | "This saved result can't be used because something it depends on is missing or has changed." | +| A saved Analyze follow-up's conversation no longer exists or isn't the caller's | "The conversation, workflow or run that holds this result no longer exists or isn't available to you." | +| Any other refusal of a saved Analyze follow-up | "This analysis is unavailable because access to its conversation or saved result could not be confirmed." | | An Analyze result cannot be saved | "The analysis could not be saved because this conversation or a selected document is no longer available." | +| An Analyze result cannot be saved because something it depends on is missing or changed | "The analysis could not be saved because something it depends on is missing or has changed." | | A generated orchestration file cannot be opened | "This file is unavailable because access to its conversation could not be confirmed." | | A chat artifact download is refused | "You no longer have access to this artifact." | +Since 0.261.234, `AnalysisResultUnavailable` picks its message by family: a source document, the container that holds the result, or the saved result itself. The [Saved-Result Error Messages Fix](../fixes/ANALYSIS_RESULT_UNAVAILABLE_MESSAGES_FIX.md) lists every code's family and the messages workflows show. + ### Configuration There are no new settings. `enable_content_screening` and `enable_content_screening_workspace_uploads` keep their meaning. The chat input and output checks (`enable_content_screening_chat_input` and `enable_content_screening_chat_output`) are unchanged and stay off by default. @@ -132,7 +137,7 @@ Layers 2 and 3a merged into layer 1's branch, `paullizer-workspace-knowledge-hol | 3a | 0.261.231 | Workflow saved results take their access from the workflow and run. | [#1631](https://github.com/microsoft/simplechat/pull/1631) | | 3b | 0.261.232 | Chat, Analyze, orchestration, generated files, history and exports (this document). | [#1632](https://github.com/microsoft/simplechat/pull/1632) | -Shared source-check helpers that are still used for input reads, such as `authorize_analysis_sources`, stay in place. Rewording `AnalysisResultUnavailable`, which no longer means a source re-check failed for saved results, is a small follow-up. +Shared source-check helpers that are still used for input reads, such as `authorize_analysis_sources`, stay in place. Version 0.261.234 rewords `AnalysisResultUnavailable`, which no longer means a source re-check failed for saved results, in the [Saved-Result Error Messages Fix](../fixes/ANALYSIS_RESULT_UNAVAILABLE_MESSAGES_FIX.md). ## Testing and validation diff --git a/docs/explanation/fixes/ANALYSIS_RESULT_UNAVAILABLE_MESSAGES_FIX.md b/docs/explanation/fixes/ANALYSIS_RESULT_UNAVAILABLE_MESSAGES_FIX.md new file mode 100644 index 000000000..dd0384b19 --- /dev/null +++ b/docs/explanation/fixes/ANALYSIS_RESULT_UNAVAILABLE_MESSAGES_FIX.md @@ -0,0 +1,103 @@ +# Saved-Result Error Messages Fix + +Fixed in version: **0.261.234** + +Related issue: [#1621](https://github.com/microsoft/simplechat/issues/1621) (follow-up). + +## Issue + +From 0.261.230 through 0.261.232, saved and generated results stopped re-checking the documents they came from. The error those checks raised, `AnalysisResultUnavailable`, kept its old wording, so people were told about source access when the actual problem was different: + +- The exception had one message for every case: "This analysis is unavailable because its source access could not be confirmed." Routes that return a `PermissionError` message passed it straight to the user. +- The workflow runner reported every `AnalysisResultUnavailable` as "A required source is no longer available for this workflow run.", including a missing or changed earlier result, a broken loop or Repeat record, and a deleted conversation or run. +- When a task's result couldn't be saved, the runner always said that "a document it read changed or became unavailable while it ran". The same message appeared when the result's own source list was malformed. +- A loop paused with "The current loop item's original source is no longer available." even when the saved collection behind the item had changed. +- The workflow progress view said "Workflow progress is unavailable because current access could not be confirmed." when a saved record behind a publication gate failed its check. +- Explaining a saved analysis, saving an Analyze result and the final publication check each used one fixed text for every case. For example, a saved result with a broken source list was reported as "this conversation or a selected document is no longer available". +- History placeholders said a saved analysis or saved workflow output was unavailable "because its access could not be confirmed". + +## Root Cause + +`AnalysisResultUnavailable` is raised at about 200 places with 53 distinct codes. Most describe a saved result whose lineage, manifest, receipt or loop and Repeat state is missing, changed or invalid. Others describe the conversation, message, workflow or run that holds the result. Only a few mean that a source document can't be read right now, and those come from input reads. The exception and every mapping that turned it into text assumed the last case. Five raises passed no code at all, so they got the source default even when they checked a message or workflow binding. + +## Technical Details + +Files modified: + +- `application/single_app/functions_analysis_access.py` +- `application/single_app/functions_document_analysis_checkpoints.py` +- `application/single_app/functions_document_analysis_results.py` +- `application/single_app/functions_saved_analysis.py` +- `application/single_app/functions_workflow_artifacts.py` +- `application/single_app/functions_workflow_runner.py` +- `application/single_app/functions_workflow_flow_runner.py` +- `application/single_app/functions_generated_artifact_sources.py` +- `application/single_app/route_backend_chats.py` +- `application/single_app/route_backend_workflows.py` +- `application/single_app/config.py` +- `functional_tests/test_analysis_unavailable_message_families.py` (new) +- `functional_tests/test_workflow_durable_routes.py` +- `functional_tests/test_analyze_backend_saved_integration.py` + +### Message families + +`functions_analysis_access.py` classifies every code into one of three families, held in explicit frozensets. Each code is classified by what its raise sites check, not by its name. + +| Family | Meaning | Frozenset | Message | +| --- | --- | --- | --- | +| Source | An input read found a source document it can't read now, or one that changed while it was read. | `ANALYSIS_SOURCE_UNAVAILABLE_CODES` | "A source document for this analysis is no longer available or has changed." | +| Container | The conversation, message, workflow or run that holds the result is gone or isn't the caller's. | `ANALYSIS_CONTAINER_UNAVAILABLE_CODES` | "The conversation, workflow or run that holds this result no longer exists or isn't available to you." | +| Saved result | The stored result, or an earlier result, manifest, receipt, or loop or Repeat state it depends on, is missing, changed or invalid. | `ANALYSIS_SAVED_RESULT_UNAVAILABLE_CODES` | "This saved result can't be used because something it depends on is missing or has changed." | + +`AnalysisResultUnavailable(code, *, family=None)` sets `code`, `family` and `public_message`, and uses `public_message` as its own text. `analysis_unavailable_family(code)` returns the family for a code. An unclassified code falls back to the saved-result wording, which blames neither a source document nor the caller's access. The functional test keeps every application raise classified. + +Three raise sites share a code with another family, so they pass `family=` explicitly: + +- `resolve_analysis_source_manifest` raises `analysis_source_manifest_invalid` as a source failure, because the current source lookup itself failed during an input read. +- `index_analysis_source_manifest` raises `analysis_source_manifest_invalid` as a source failure when an assigned document was not authorized by the current lookup. +- `load_workflow_artifact_binding` raises `generated_artifact_source_unavailable` as a saved-result failure, because a saved output's run records could not be loaded. The workflow and run scope checks raise the same code as a container failure. + +The five raises that passed no code now pass one. `authorize_analysis_sources` (an invalid reader or an authorization failure) and `AnalysisWorkUnitCheckpoints.validate_sources` keep `analysis_source_unavailable`. `_load_authorized_message` now raises `analysis_message_unavailable`, and `_load_authorized_workflow` raises `analysis_workflow_unavailable`. Both are container failures. No route, V2 code or test branched on their old default code, and they map to the same response codes as before: `workflow_result_access_denied` for chat follow-ups, `output_access_denied` for generated files and `workflow_result_invalid` for the workflow result reader. + +Loop document re-authorization (`_authorize_frozen_document`) passes the `WorkflowLoopInputError` code through. Those codes (`workflow_loop_input_unavailable`, `workflow_loop_source_changed` and `workflow_loop_scope_forbidden`) are source codes. + +### Where the family now picks the text + +| Place | Source | Container | Saved result | +| --- | --- | --- | --- | +| Workflow task error | "A required source is no longer available for this workflow run." (unchanged) | "A conversation, workflow or run that this task needs is no longer available." | "A saved result this task needs can't be used because something it depends on is missing or has changed." | +| Task result not saved | "The analysis result was not saved because a document it read changed or became unavailable while it ran. Dependent tasks were not run." (unchanged) | "The analysis result was not saved because the conversation, workflow or run that holds it is no longer available. Dependent tasks were not run." | "The analysis result was not saved because something it depends on is missing or has changed. Dependent tasks were not run." | +| Loop item pause | "The current loop item's original source is no longer available." (unchanged) | "The conversation, workflow or run that holds the current loop item's input is no longer available." | "The current loop item's saved input can't be used because something it depends on is missing or has changed." | +| Saved analysis explanation | The family message | The family message | The family message | +| Analyze save | Unchanged combined message | Unchanged combined message | "The analysis could not be saved because something it depends on is missing or has changed." | +| Analyze publication check | "The analysis could not be saved because a selected document is no longer available or has changed." | "This analysis conversation is unavailable." (unchanged) | "The analysis could not be saved because something it depends on is missing or has changed." | +| Workflow progress | "Workflow progress is unavailable." followed by the family message | Same | Same | + +The runner keys its text by the family's string value with a source default, so the 70 functional test files that compile the runner from source keep working without new names. Other `PermissionError` refusals keep their existing text: an explanation still says "This analysis is unavailable because access to its conversation or saved result could not be confirmed.", and workflow progress still says "Workflow progress is unavailable because current access could not be confirmed." + +History placeholders no longer blame access confirmation: + +- A saved analysis that can't be shown: "This saved analysis is unavailable because it or something it depends on is missing, has changed or can't be read right now." +- A saved workflow output that can't be shown: "Saved workflow output is unavailable because it or the workflow run it came from is missing, has changed or can't be read right now." + +### Not changed + +- The exception class, every code (except the two former bare raises above), every raise and catch site, HTTP statuses and response `code` values. +- Genuine source-authority messages: `content_screening/contracts.py`, and the orchestration plan input check in `route_backend_orchestration.py`. +- Code-based mappings that already used neutral text or closed codes: task result pages (409), execution history (409), activity streams, chat follow-up refusal codes, generated file output codes and orchestration failure codes. Their codes are shared with genuine access refusals, so their text can't vary by family without changing the code. +- Loop input and Repeat pauses whose text already said "no longer available or could not be verified". + +## Validation + +`functional_tests/test_analysis_unavailable_message_families.py`: + +- Scans every `AnalysisResultUnavailable(...)` call in `application/single_app` and fails for a bare raise, an unclassified code, an unknown `family=` value, or a computed code without a reviewed resolution. It resolves the one computed code by walking the `WorkflowLoopInputError` codes that `reauthorize_workflow_loop_document` can raise. +- Pins the three `family=` overrides, keeps the frozensets disjoint, and checks each family's message and that no message mentions source access or promises a retry. +- Raises real input reads and binding checks to prove their codes and families, and that the renamed container codes keep every response code. +- Runs the real task sequence, through the shared inventory harness, for each family: a task that can't read its input and a result that can't be saved. Each records the family's message on the failed task. +- Posts to the chat routes through the shared Analyze harness: explanation, save and publication check return 403 with the family's message, and other refusals keep their old text. +- Checks the loop pause reasons and the history placeholders, and that no application file still says "source access could not be confirmed". + +`functional_tests/test_workflow_durable_routes.py` checks that workflow progress names a failed saved record with the family message, and that a group membership refusal keeps its access message. + +Against the unchanged 0.261.232 code, the new test file fails at import, because the family constants don't exist there. The old single message would fail every message check, and the five bare raises fail the scan. diff --git a/docs/explanation/fixes/WORKFLOW_TEST_HARNESS_REPAIR_FIX.md b/docs/explanation/fixes/WORKFLOW_TEST_HARNESS_REPAIR_FIX.md new file mode 100644 index 000000000..c8c15e54a --- /dev/null +++ b/docs/explanation/fixes/WORKFLOW_TEST_HARNESS_REPAIR_FIX.md @@ -0,0 +1,67 @@ +# Workflow Test Harness Repair Fix + +Fixed in version: **0.261.234** + +Related issue: [#1621](https://github.com/microsoft/simplechat/issues/1621) (follow-up). This change touches only functional tests. + +## Issue + +Several workflow functional tests could not reach their assertions on `paullizer-react-v2-ui`, because their harnesses had fallen behind the application. Nothing flagged it, because CI doesn't run these files. + +- `test_workflow_task_result_handoff.py`: every Analyze inventory run failed with "Workflow task 'Extract inventory' failed after 1 attempt(s)" (9 tests). +- `test_workflow_data_flow_execution.py`: 8 tests failed, including all three cases of `test_real_run_entrypoint_persists_the_deliverable_outcome`. +- `test_workflow_runtime_integration.py`: 4 durable runs ended as `failed` instead of completing or waiting for approval. +- `test_workflow_m365_rebase_integration.py`: 9 Microsoft 365 wait and continuation tests failed the same way, through the shared runtime fixture. +- `test_analyze_workflow_publication_integration.py`: all 8 tests errored at setup. +- The group workflow round-trip harness, and every file that reuses it, failed with `ModuleNotFoundError: No module named 'functions_workflow_chat_delivery'` whenever `application/single_app` wasn't on `PYTHONPATH`: 13 files and 1,857 failing tests. +- `test_analyze_artifact_phase7_rollout_rollback.py` left fake `functions_generated_file_exports` and `functions_assistant_table_exports` modules in `sys.modules`. In the same pytest process, later files failed at import, for example `cannot import name 'build_generated_file_export' from 'functions_generated_file_exports'`. + +## Root Cause + +The harnesses compile production functions from source into a namespace, or load real modules from their files, and supply every global those functions use. Production code gained dependencies the harnesses didn't supply: + +- `functions_document_analysis.py` now calls `generated_file_publication_allowed()` from `functions_orchestration_execution_policy.py`. The `NameError` was caught by the task runner, so it surfaced only as a failed task. +- `_run_authorized_workflow_impl` now applies the calendar run-time context through `_apply_workflow_run_time_context`, which reads `WORKFLOW_SCHEDULED_TRIGGER_TYPES` and `workflow_run_time_context` from `functions_workflow_schedules.py`. This failed every run that went through the real run entrypoint. +- `_run_personal_workflow_impl` now authorizes Microsoft 365 actions inside `_ensure_execution_context`. That function compares the Flask `session` user with the workflow owner, and for a run without a signed-in owner it looks the owner up with `read_user_settings_snapshot`. The entrypoint test supplied neither, nor `workflow_m365_manifests` or the run store read that the entrypoint now makes, and its assistant-tracking fake didn't accept the new `assistant_message_id` argument. +- `functions_workflow_context.py` now resolves each request's limits through `resolve_model_token_budget` from `functions_model_capabilities.py`. The publication fixture still patched the removed `resolve_model_token_limits`. +- `functions_personal_workflows.py` now imports `functions_workflow_chat_delivery`. The round-trip harness loads each real module from its file, in import-graph order, without the application folder on the import path, and its list didn't include the chat delivery contract. With the folder on the path, the harness imported the contract inside its stub scope and left that copy, bound to its stub logging module, in `sys.modules`. +- The Phase 7 rollout test installed its lightweight planner stubs at import time with `sys.modules.setdefault` and never removed them. + +## Technical Details + +Files modified: + +- `functional_tests/test_workflow_task_result_handoff.py` +- `functional_tests/test_workflow_data_flow_execution.py` +- `functional_tests/test_analyze_workflow_publication_integration.py` +- `functional_tests/test_group_workflow_round_trip_preservation.py` +- `functional_tests/test_workflow_draft_save_parity.py` +- `functional_tests/test_analyze_artifact_phase7_rollout_rollback.py` + +Changes: + +- **Inventory harness (`build_inventory_run`).** The Analyze namespace gets the real `generated_file_publication_allowed`; no orchestration task restricts these runs' files. The runner namespace gets the real `WORKFLOW_SCHEDULED_TRIGGER_TYPES` and `workflow_run_time_context`. All three have no application dependencies. This repairs the handoff, data-flow, runtime integration and Microsoft 365 rebase tests that run the real sequence or entrypoint. +- **Run entrypoint test.** The owner starts the run from a signed-in request, so the test supplies a Flask-style `session` for the owner and `_ensure_execution_context` takes its real reuse path. `read_user_settings_snapshot` fails the test if it is called, which proves the context was reused rather than rebuilt. The test also supplies `workflow_m365_manifests` for a workflow with no Microsoft 365 actions, a run store read that returns what the entrypoint saved, and an assistant-tracking fake that reuses a stored assistant message id, as the real helper does. +- **Publication fixture.** It now patches `functions_workflow_context.resolve_model_token_budget` with the same `model_budget` helper the Analyze chat fixture uses, keeping the original 1,000,000-token limits. With the limits applied, the saved report fits one prompt again, so the completion fake still checks that all 150 rows arrive together. The small-model test keeps its 16,000-token limits and still pages all 150 records. No fake was loosened. +- **Round-trip harness.** `functions_workflow_chat_delivery` joins the real-module list after `functions_m365_workflow_binding`, its dependency, in both the group round-trip harness and the save parity harness. The harness now loads the contract from its file and removes it afterwards, like every other module it manages. +- **Phase 7 rollout test.** A module-scoped `planner` fixture installs the stubs with `pytest.MonkeyPatch`, only for dependencies that aren't imported yet. It imports the planner over them, and on exit removes the planner if this scope imported it. The tests take the planner from the fixture, and the `__main__` runner uses the same context manager. + +No application code or assertion changed for these repairs. + +## Validation + +Each file was run in its own pytest process, with `application/single_app` on `PYTHONPATH`, against an untouched worktree of the base commit and against this branch: + +| File | Base | This branch | +| --- | --- | --- | +| `test_workflow_task_result_handoff.py` | 9 failed | all pass | +| `test_workflow_data_flow_execution.py` | 8 failed | all pass | +| `test_workflow_runtime_integration.py` | 4 failed | all pass | +| `test_workflow_m365_rebase_integration.py` | 9 failed | all pass | +| `test_analyze_workflow_publication_integration.py` | 8 errors | all pass | + +`test_native_source_revocation_does_not_block_the_saved_report`, which proves that revoking a native source after its result is saved doesn't block the report, now runs and passes. + +Without `application/single_app` on `PYTHONPATH`, the 13 files built on the round-trip harness went from 1,857 failing tests to none. + +`test_analyze_artifact_phase7_rollout_rollback.py`, run in one pytest process with `test_generated_artifact_paging_and_guidance.py`, `test_orchestration_output_downloads.py`, `test_orchestration_result_claim_fencing.py`, `test_workflow_loop_limits.py`, `test_workflow_result_masking.py` and `test_workflow_runtime_integration.py`, went from 5 collection errors to 101 passed. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 0e3ff43a4..bf04ddc59 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,19 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.234)** + +#### Bug Fixes + +* **Clearer Messages When A Saved Result Can't Be Used** + * Errors about saved and generated results no longer say that source access could not be confirmed. Since saved results stopped re-checking their source documents, most of these errors mean something else, and the message now says which kind of problem it is. + * A source document that can't be read now, or that changed while it was read: "A source document for this analysis is no longer available or has changed." Only reading an uploaded document as an input can raise this. + * A conversation, workflow or run that no longer exists or isn't yours: "The conversation, workflow or run that holds this result no longer exists or isn't available to you." + * A saved result whose earlier result, record or loop and Repeat state is missing or changed: "This saved result can't be used because something it depends on is missing or has changed." + * Workflow task errors, a task result that couldn't be saved, a paused loop item, saved analysis explanations, Analyze saves, the Analyze publication check and the workflow progress view now show the matching message. Placeholders in chat history for an unavailable saved analysis or saved workflow output no longer mention access confirmation. + * Error codes and HTTP statuses are unchanged, apart from two internal codes for a message or workflow binding mismatch (`analysis_message_unavailable` and `analysis_workflow_unavailable`) that map to the same response codes as before. + * (Ref: #1621, `functions_analysis_access.py`, `functions_workflow_runner.py`, `functions_workflow_flow_runner.py`, `route_backend_chats.py`, `route_backend_workflows.py`, `test_analysis_unavailable_message_families.py`, [Saved-Result Error Messages Fix](fixes/ANALYSIS_RESULT_UNAVAILABLE_MESSAGES_FIX.md)) + ### **(v0.261.233)** #### Bug Fixes diff --git a/functional_tests/test_analysis_unavailable_message_families.py b/functional_tests/test_analysis_unavailable_message_families.py new file mode 100644 index 000000000..375a422e9 --- /dev/null +++ b/functional_tests/test_analysis_unavailable_message_families.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +# test_analysis_unavailable_message_families.py +""" +Functional test for the wording of AnalysisResultUnavailable by message family. +Version: 0.261.234 +Implemented in: 0.261.234 + +This test ensures every AnalysisResultUnavailable raised in application/single_app has a +determined message family (a source document, the conversation, workflow or run that holds a +result, or the saved result itself), that each family has its own plain wording, that the +workflow runner, chat routes and progress route show the family's message, and that the +codes and HTTP statuses callers depend on are unchanged. +""" + +import ast +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT / "application" / "single_app" +sys.path.insert(0, str(APP_ROOT)) + +# Application and fixture imports follow the module-path setup. +from functions_analysis_access import ( # noqa: E402 + ANALYSIS_CONTAINER_UNAVAILABLE_CODES, + ANALYSIS_SAVED_RESULT_UNAVAILABLE_CODES, + ANALYSIS_SOURCE_UNAVAILABLE_CODES, + ANALYSIS_UNAVAILABLE_CONTAINER, + ANALYSIS_UNAVAILABLE_MESSAGES, + ANALYSIS_UNAVAILABLE_SAVED_RESULT, + ANALYSIS_UNAVAILABLE_SOURCE, + AnalysisResultUnavailable, + analysis_unavailable_family, + authorize_analysis_sources, + resolve_analysis_source_manifest, +) +from functions_document_analysis_results import index_analysis_source_manifest # noqa: E402 +from test_analyze_backend_saved_integration import analyze_body, chat, followup_body, saved, saved_chat # noqa: E402,F401 +from test_support.app_stubs import stubbed_config # noqa: E402 +from test_support.versioning import assert_app_version_at_least # noqa: E402 +from test_workflow_task_result_handoff import build_inventory_run # noqa: E402 + + +IMPLEMENTED_VERSION = "0.261.234" +SOURCE_MESSAGE = "A source document for this analysis is no longer available or has changed." +CONTAINER_MESSAGE = ( + "The conversation, workflow or run that holds this result no longer exists or isn't available to you." +) +SAVED_RESULT_MESSAGE = "This saved result can't be used because something it depends on is missing or has changed." +FAMILY_CODES = { + ANALYSIS_UNAVAILABLE_SOURCE: ANALYSIS_SOURCE_UNAVAILABLE_CODES, + ANALYSIS_UNAVAILABLE_CONTAINER: ANALYSIS_CONTAINER_UNAVAILABLE_CODES, + ANALYSIS_UNAVAILABLE_SAVED_RESULT: ANALYSIS_SAVED_RESULT_UNAVAILABLE_CODES, +} +FAMILY_CONSTANTS = { + "ANALYSIS_UNAVAILABLE_SOURCE": ANALYSIS_UNAVAILABLE_SOURCE, + "ANALYSIS_UNAVAILABLE_CONTAINER": ANALYSIS_UNAVAILABLE_CONTAINER, + "ANALYSIS_UNAVAILABLE_SAVED_RESULT": ANALYSIS_UNAVAILABLE_SAVED_RESULT, +} +CODE_FAMILIES = {code: family for family, codes in FAMILY_CODES.items() for code in codes} +# The one raise whose code is computed: loop document re-authorization passes its own code through. +# Its possible codes are resolved from functions_workflow_loop_inputs below. +RESOLVED_DYNAMIC_RAISES = {("functions_workflow_iterations.py", "_authorize_frozen_document")} +# One representative code per family, raised for real by the runner and route checks below. +FAMILY_SAMPLES = { + ANALYSIS_UNAVAILABLE_SOURCE: "analysis_source_snapshot_changed", + ANALYSIS_UNAVAILABLE_CONTAINER: "analysis_conversation_deleted", + ANALYSIS_UNAVAILABLE_SAVED_RESULT: "analysis_lineage_invalid", +} + + +def _name_of(node): + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return None + + +def _family_of(node): + if isinstance(node, ast.Constant) and node.value in ANALYSIS_UNAVAILABLE_MESSAGES: + return node.value + return FAMILY_CONSTANTS.get(_name_of(node)) + + +def _raise_sites(): + for path in sorted(APP_ROOT.rglob("*.py")): + source = path.read_text(encoding="utf-8") + if "AnalysisResultUnavailable" not in source: + continue + tree = ast.parse(source, filename=str(path)) + enclosing = {} + # Breadth-first, so a nested function's name replaces its parent's for its own body. + for function in ast.walk(tree): + if isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): + for child in ast.walk(function): + enclosing[child] = function.name + for node in ast.walk(tree): + if isinstance(node, ast.Call) and _name_of(node.func) == "AnalysisResultUnavailable": + yield path.relative_to(APP_ROOT).as_posix(), node, enclosing.get(node) + + +def _site_problem(path, node, function): + location = f"{path}:{node.lineno} in {function or 'module scope'}" + keywords = {keyword.arg: keyword.value for keyword in node.keywords} + if None in keywords or any(isinstance(argument, ast.Starred) for argument in node.args): + return f"{location}: pass the code and family explicitly, not through * or ** arguments" + if len(node.args) > 1: + return f"{location}: family is keyword-only" + code = node.args[0] if node.args else keywords.get("code") + family = keywords.get("family") + if family is not None and _family_of(family) is None: + return f"{location}: family= must be one of the ANALYSIS_UNAVAILABLE_* constants" + if code is None: + return f"{location}: a bare raise relies on the default code; pass the code that describes this check" + if isinstance(code, ast.Constant) and isinstance(code.value, str): + if code.value not in CODE_FAMILIES: + return f"{location}: {code.value!r} is not classified in functions_analysis_access" + return None + if (path, function) in RESOLVED_DYNAMIC_RAISES or family is not None: + return None + return f"{location}: a computed code needs family= or a reviewed resolution in this test" + + +def _loop_reauthorization_codes(): + """Every WorkflowLoopInputError code reauthorize_workflow_loop_document can raise.""" + limits = ast.parse((APP_ROOT / "functions_workflow_limits.py").read_text(encoding="utf-8")) + defaults = {} + for node in limits.body: + if isinstance(node, ast.ClassDef) and node.name in {"WorkflowLoopInputError", "WorkflowLoopLimitError"}: + init = next(item for item in node.body if isinstance(item, ast.FunctionDef) and item.name == "__init__") + for argument, default in zip(init.args.kwonlyargs, init.args.kw_defaults): + if argument.arg == "code": + defaults[node.name] = default.value + tree = ast.parse((APP_ROOT / "functions_workflow_loop_inputs.py").read_text(encoding="utf-8")) + functions = {node.name: node for node in tree.body if isinstance(node, ast.FunctionDef)} + pending, visited, codes = ["reauthorize_workflow_loop_document"], set(), set() + while pending: + name = pending.pop() + if name in visited: + continue + visited.add(name) + for node in ast.walk(functions[name]): + if isinstance(node, ast.Name) and node.id in functions: + pending.append(node.id) + if isinstance(node, ast.Call) and _name_of(node.func) in defaults: + code = next((keyword.value for keyword in node.keywords if keyword.arg == "code"), None) + if code is None: + codes.add(defaults[_name_of(node.func)]) + else: + codes.add(code.value if isinstance(code, ast.Constant) else None) + return codes + + +def test_every_application_raise_has_a_determined_family(): + """A new raise must name a classified code, so it can't silently get another family's text.""" + print("Testing every AnalysisResultUnavailable raise for a determined message family...") + assert_app_version_at_least(IMPLEMENTED_VERSION) + sites = list(_raise_sites()) + problems = [problem for problem in (_site_problem(*site) for site in sites) if problem] + assert not problems, "Unclassified AnalysisResultUnavailable raises:\n" + "\n".join(problems) + literal_codes = { + site[1].args[0].value for site in sites + if site[1].args and isinstance(site[1].args[0], ast.Constant) + } + # The scan is meaningful only if it still finds the raises it was written against. + assert len(sites) >= 150 + assert {CODE_FAMILIES[code] for code in literal_codes} == set(FAMILY_CODES) + print("Test passed!") + + +def test_family_overrides_are_explicit_at_shared_code_sites(): + """Shared codes whose raise site checks something else carry family= at that site only.""" + overrides = { + (path, node.args[0].value, _family_of(keyword.value)) + for path, node, _function in _raise_sites() + for keyword in node.keywords if keyword.arg == "family" + } + assert overrides == { + ("functions_analysis_access.py", "analysis_source_manifest_invalid", ANALYSIS_UNAVAILABLE_SOURCE), + ("functions_document_analysis_results.py", "analysis_source_manifest_invalid", ANALYSIS_UNAVAILABLE_SOURCE), + ("functions_workflow_artifacts.py", "generated_artifact_source_unavailable", ANALYSIS_UNAVAILABLE_SAVED_RESULT), + } + + +def test_loop_document_codes_passed_through_are_source_codes(): + """The computed code in _authorize_frozen_document comes from a loop input read.""" + source = (APP_ROOT / "functions_workflow_iterations.py").read_text(encoding="utf-8") + function = next( + node for node in ast.parse(source).body + if isinstance(node, ast.FunctionDef) and node.name == "_authorize_frozen_document" + ) + body = ast.unparse(function) + assert "reauthorize_workflow_loop_document(" in body + assert "except WorkflowLoopInputError as exc" in body + assert "AnalysisResultUnavailable(exc.code)" in body + codes = _loop_reauthorization_codes() + assert None not in codes, "A loop input code could not be resolved statically." + assert "workflow_loop_input_unavailable" in codes and "workflow_loop_source_changed" in codes + assert codes <= ANALYSIS_SOURCE_UNAVAILABLE_CODES + + +def test_family_code_sets_are_disjoint_and_every_family_has_a_message(): + families = list(FAMILY_CODES.values()) + for index, codes in enumerate(families): + for other in families[index + 1:]: + assert not codes & other + assert set(ANALYSIS_UNAVAILABLE_MESSAGES) == set(FAMILY_CODES) + assert ANALYSIS_UNAVAILABLE_MESSAGES == { + ANALYSIS_UNAVAILABLE_SOURCE: SOURCE_MESSAGE, + ANALYSIS_UNAVAILABLE_CONTAINER: CONTAINER_MESSAGE, + ANALYSIS_UNAVAILABLE_SAVED_RESULT: SAVED_RESULT_MESSAGE, + } + for message in ANALYSIS_UNAVAILABLE_MESSAGES.values(): + assert "source access" not in message and "could not be confirmed" not in message + assert "retry" not in message.lower() and "try again" not in message.lower() + + +@pytest.mark.parametrize("code,family,message", [ + ("analysis_source_unavailable", ANALYSIS_UNAVAILABLE_SOURCE, SOURCE_MESSAGE), + ("analysis_source_snapshot_changed", ANALYSIS_UNAVAILABLE_SOURCE, SOURCE_MESSAGE), + ("workflow_loop_source_changed", ANALYSIS_UNAVAILABLE_SOURCE, SOURCE_MESSAGE), + ("analysis_conversation_deleted", ANALYSIS_UNAVAILABLE_CONTAINER, CONTAINER_MESSAGE), + ("generated_artifact_source_unavailable", ANALYSIS_UNAVAILABLE_CONTAINER, CONTAINER_MESSAGE), + ("analysis_message_masked", ANALYSIS_UNAVAILABLE_CONTAINER, CONTAINER_MESSAGE), + ("analysis_lineage_invalid", ANALYSIS_UNAVAILABLE_SAVED_RESULT, SAVED_RESULT_MESSAGE), + ("analysis_native_source_mismatch", ANALYSIS_UNAVAILABLE_SAVED_RESULT, SAVED_RESULT_MESSAGE), + ("workflow_repeat_state_changed", ANALYSIS_UNAVAILABLE_SAVED_RESULT, SAVED_RESULT_MESSAGE), +]) +def test_each_family_has_its_own_message_and_keeps_its_code(code, family, message): + error = AnalysisResultUnavailable(code) + assert isinstance(error, PermissionError) + assert error.code == code + assert error.family == family == analysis_unavailable_family(code) + assert error.public_message == str(error) == message + assert error.args == (message,) + + +def test_default_override_and_unclassified_codes(): + default = AnalysisResultUnavailable() + assert (default.code, default.family, str(default)) == ( + "analysis_source_unavailable", ANALYSIS_UNAVAILABLE_SOURCE, SOURCE_MESSAGE, + ) + shared = AnalysisResultUnavailable("analysis_source_manifest_invalid", family=ANALYSIS_UNAVAILABLE_SOURCE) + assert (shared.code, shared.family, str(shared)) == ( + "analysis_source_manifest_invalid", ANALYSIS_UNAVAILABLE_SOURCE, SOURCE_MESSAGE, + ) + # Unknown codes and families never blame a source document or the caller's access. + assert AnalysisResultUnavailable("a_future_code").family == ANALYSIS_UNAVAILABLE_SAVED_RESULT + assert AnalysisResultUnavailable("analysis_lineage_invalid", family="unknown").family == ( + ANALYSIS_UNAVAILABLE_SAVED_RESULT + ) + + +def test_input_reads_keep_their_codes_and_say_a_source_is_unavailable(): + with pytest.raises(AnalysisResultUnavailable) as lookup: + resolve_analysis_source_manifest(["document-1"], "owner", resolver=lambda ids, **kwargs: []) + assert (lookup.value.code, lookup.value.family) == ("analysis_source_manifest_invalid", ANALYSIS_UNAVAILABLE_SOURCE) + with pytest.raises(AnalysisResultUnavailable) as reader: + authorize_analysis_sources("", [{"document_id": "document-1", "scope": "personal", "scope_id": "owner"}]) + assert (reader.value.code, reader.value.family) == ("analysis_source_unavailable", ANALYSIS_UNAVAILABLE_SOURCE) + unauthorized = [{ + "document_id": "document-1", "scope": "personal", "scope_id": "owner", + "source_version": 1, "authorization_status": "unresolved", + }] + with pytest.raises(AnalysisResultUnavailable) as assigned: + index_analysis_source_manifest(unauthorized, ["document-1"]) + assert (assigned.value.code, assigned.value.family) == ("analysis_source_manifest_invalid", ANALYSIS_UNAVAILABLE_SOURCE) + with pytest.raises(AnalysisResultUnavailable) as incomplete: + index_analysis_source_manifest([{**unauthorized[0], "authorization_status": "authorized"}], ["document-1", "document-2"]) + assert (incomplete.value.code, incomplete.value.family) == ( + "analysis_source_manifest_missing", ANALYSIS_UNAVAILABLE_SAVED_RESULT, + ) + + +def test_saved_output_records_that_cannot_load_are_a_saved_result_failure(monkeypatch): + import functions_workflow_artifacts as artifacts + + def unreadable(*args, **kwargs): + raise ValueError("The saved output's run records are invalid.") + + monkeypatch.setattr(artifacts, "_load_workflow_artifact_binding", unreadable) + with pytest.raises(AnalysisResultUnavailable) as refused: + artifacts.load_workflow_artifact_binding("owner", {}) + assert (refused.value.code, refused.value.family) == ( + "generated_artifact_source_unavailable", ANALYSIS_UNAVAILABLE_SAVED_RESULT, + ) + + +def _module(name, **attributes): + module = ModuleType(name) + module.__dict__.update(attributes) + return module + + +def test_former_bare_raises_name_the_container_that_failed(monkeypatch): + conversations = SimpleNamespace(read_item=lambda item, partition_key: {"id": item, "user_id": "owner"}) + messages = SimpleNamespace(read_item=lambda item, partition_key: {"id": item, "conversation_id": "elsewhere"}) + monkeypatch.setitem(sys.modules, "functions_collaboration", _module( + "functions_collaboration", build_conversation_participation_context=lambda user_id, conversation: None, + assert_user_can_view_collaboration_conversation=lambda *args, **kwargs: None, + get_collaboration_conversation=lambda *args: pytest.fail("A personal message is read from its conversation."), + get_collaboration_message=lambda *args: pytest.fail("A personal message is read from its conversation."), + )) + monkeypatch.setitem(sys.modules, "functions_group", _module("functions_group", assert_group_role=lambda *args, **kwargs: None)) + monkeypatch.setitem(sys.modules, "functions_group_workflows", _module( + "functions_group_workflows", get_group_workflow=lambda *args: None, get_group_workflow_run=lambda *args: None, + get_group_workflow_run_item=lambda *args: None, + )) + monkeypatch.setitem(sys.modules, "functions_personal_workflows", _module( + "functions_personal_workflows", get_personal_workflow=lambda *args: None, + get_personal_workflow_run=lambda *args: None, get_personal_workflow_run_item=lambda *args: None, + )) + monkeypatch.setitem(sys.modules, "functions_workflow_runner", _module( + "functions_workflow_runner", _workflow_task_run_item_id=lambda run_id, task_id, *args: f"{run_id}:{task_id}", + )) + with stubbed_config(cosmos_conversations_container=conversations, cosmos_messages_container=messages): + with pytest.raises(AnalysisResultUnavailable) as message: + saved._load_authorized_message("owner", "conversation-1", "message-1") + with pytest.raises(AnalysisResultUnavailable) as workflow: + saved._load_authorized_workflow("owner", {"workflow_id": "workflow-1", "run_id": "run-1", "task_id": "task-1"}) + assert (message.value.code, message.value.family) == ("analysis_message_unavailable", ANALYSIS_UNAVAILABLE_CONTAINER) + assert (workflow.value.code, workflow.value.family) == ("analysis_workflow_unavailable", ANALYSIS_UNAVAILABLE_CONTAINER) + + +@pytest.mark.parametrize("code", ["analysis_source_unavailable", "analysis_message_unavailable", "analysis_workflow_unavailable"]) +def test_renamed_container_codes_keep_every_response_code(code): + from functions_orchestration_rendering import _source_visibility_code + from functions_workflow_result_followup import _analysis_refusal_code + from functions_workflow_result_reader import _authorization_code + + error = AnalysisResultUnavailable(code) + assert _analysis_refusal_code(error) == "workflow_result_access_denied" + assert _source_visibility_code(error) == "output_access_denied" + assert _authorization_code(error) == "workflow_result_invalid" + + +RUNNER_TASK_ERRORS = { + ANALYSIS_UNAVAILABLE_SOURCE: "A required source is no longer available for this workflow run.", + ANALYSIS_UNAVAILABLE_CONTAINER: "A conversation, workflow or run that this task needs is no longer available.", + ANALYSIS_UNAVAILABLE_SAVED_RESULT: ( + "A saved result this task needs can't be used because something it depends on is missing or has changed." + ), +} +RUNNER_NOT_SAVED_ERRORS = { + ANALYSIS_UNAVAILABLE_SOURCE: ( + "The analysis result was not saved because a document it read changed or became unavailable while it ran. " + "Dependent tasks were not run." + ), + ANALYSIS_UNAVAILABLE_CONTAINER: ( + "The analysis result was not saved because the conversation, workflow or run that holds it is no longer " + "available. Dependent tasks were not run." + ), + ANALYSIS_UNAVAILABLE_SAVED_RESULT: ( + "The analysis result was not saved because something it depends on is missing or has changed. " + "Dependent tasks were not run." + ), +} + + +@pytest.mark.parametrize("family", list(FAMILY_SAMPLES)) +def test_runner_reports_the_family_message_for_a_task_that_cannot_read_its_input(family): + runner, workflow, records, requests, artifacts, items = build_inventory_run() + workflow["error_handling"] = {"strategy": "halt", "retry_count": 2} + + def refuse(*args, **kwargs): + raise AnalysisResultUnavailable(FAMILY_SAMPLES[family]) + + runner["_execute_workflow_dispatch"] = refuse + with pytest.raises(RuntimeError) as caught: + runner["_execute_workflow_task_sequence"](workflow, {}, "conversation-inventory", "run-inventory", None, {}) + expected = RUNNER_TASK_ERRORS[family] + assert str(caught.value) == f"Workflow task 'Extract inventory' failed after 1 attempt(s): {expected}" + failed = items[("run-inventory", runner["_workflow_task_run_item_id"]("run-inventory", "extract"))] + assert (failed["status"], failed["attempt_count"], failed["error"]) == ("failed", 1, expected) + assert requests == [] + + +@pytest.mark.parametrize("family", list(FAMILY_SAMPLES)) +def test_runner_reports_the_family_message_when_a_result_is_not_saved(family): + runner, workflow, records, requests, artifacts, items = build_inventory_run() + + def refuse(*args, **kwargs): + raise AnalysisResultUnavailable(FAMILY_SAMPLES[family]) + + runner["persist_workflow_task_result"] = refuse + expected = RUNNER_NOT_SAVED_ERRORS[family] + with pytest.raises(RuntimeError) as caught: + runner["_execute_workflow_task_sequence"](workflow, {}, "conversation-inventory", "run-inventory", None, {}) + assert str(caught.value) == expected + failed = items[("run-inventory", runner["_workflow_task_run_item_id"]("run-inventory", "extract"))] + assert (failed["status"], failed["error"]) == ("failed", expected) + assert not any(item.get("task_id") == "consume" for item in items.values()) + + +def test_loop_item_pause_reasons_cover_every_family(): + from functions_workflow_flow_runner import LOOP_ITEM_UNAVAILABLE_REASONS + + assert set(LOOP_ITEM_UNAVAILABLE_REASONS) == set(FAMILY_CODES) + assert LOOP_ITEM_UNAVAILABLE_REASONS[ANALYSIS_UNAVAILABLE_SOURCE] == ( + "The current loop item's original source is no longer available." + ) + for family in (ANALYSIS_UNAVAILABLE_CONTAINER, ANALYSIS_UNAVAILABLE_SAVED_RESULT): + assert "source" not in LOOP_ITEM_UNAVAILABLE_REASONS[family] + + +@pytest.mark.parametrize("family", list(FAMILY_SAMPLES)) +def test_saved_analysis_explanation_shows_the_family_message(chat, family): + def refuse(*args, **kwargs): + raise AnalysisResultUnavailable(FAMILY_SAMPLES[family]) + + chat.namespace["load_saved_analysis_input"] = refuse + response = chat.client.post("/api/chat", json=followup_body(chat)) + assert response.status_code == 403 + payload = response.get_json() + assert payload["error"] == ANALYSIS_UNAVAILABLE_MESSAGES[family] + assert payload["warning_type"] == "saved_analysis_unavailable" + assert chat.state["model_calls"] == [] + + +def test_saved_analysis_explanation_keeps_its_access_message_for_other_refusals(chat): + def refuse(*args, **kwargs): + raise PermissionError("Not a participant.") + + chat.namespace["load_saved_analysis_input"] = refuse + response = chat.client.post("/api/chat", json=followup_body(chat)) + assert response.status_code == 403 + assert response.get_json()["error"] == ( + "This analysis is unavailable because access to its conversation or saved result could not be confirmed." + ) + + +@pytest.mark.parametrize("family,message", [ + (ANALYSIS_UNAVAILABLE_SOURCE, + "The analysis could not be saved because this conversation or a selected document is no longer available."), + (ANALYSIS_UNAVAILABLE_CONTAINER, + "The analysis could not be saved because this conversation or a selected document is no longer available."), + (ANALYSIS_UNAVAILABLE_SAVED_RESULT, + "The analysis could not be saved because something it depends on is missing or has changed."), +]) +def test_analyze_save_failure_shows_the_family_message(chat, family, message): + def refuse(*args, **kwargs): + raise AnalysisResultUnavailable(FAMILY_SAMPLES[family]) + + chat.namespace["save_chat_analysis"] = refuse + response = chat.client.post("/api/chat/document-action", json=analyze_body()) + assert response.status_code == 403 + assert response.get_json()["error"] == message + assert response.get_json()["warning_type"] == "analysis_save_failed" + assert chat.state["rollbacks"] == 1 + + +@pytest.mark.parametrize("family,message", [ + (ANALYSIS_UNAVAILABLE_SOURCE, + "The analysis could not be saved because a selected document is no longer available or has changed."), + (ANALYSIS_UNAVAILABLE_CONTAINER, "This analysis conversation is unavailable."), + (ANALYSIS_UNAVAILABLE_SAVED_RESULT, + "The analysis could not be saved because something it depends on is missing or has changed."), +]) +def test_analyze_publication_check_shows_the_family_message(chat, family, message): + assistants_before = {key for key, item in chat.messages.documents.items() if item["role"] == "assistant"} + + def refuse(*args, **kwargs): + raise AnalysisResultUnavailable(FAMILY_SAMPLES[family]) + + chat.namespace["assert_analysis_attempt_current"] = refuse + response = chat.client.post("/api/chat/document-action", json=analyze_body()) + assert response.status_code == 403 + assert response.get_json() == {"error": message, "conversation_id": "conversation-1"} + assert chat.state["rollbacks"] == 1 + assert {key for key, item in chat.messages.documents.items() if item["role"] == "assistant"} == assistants_before + + +def test_history_placeholders_no_longer_blame_access_confirmation(): + from functions_generated_artifact_sources import _UNAVAILABLE_HISTORY + + for placeholder in (saved.UNAVAILABLE_ANALYSIS_MESSAGE, _UNAVAILABLE_HISTORY): + assert "could not be confirmed" not in placeholder + assert "missing, has changed or can't be read right now" in placeholder + + +def test_no_application_text_says_source_access_could_not_be_confirmed(): + candidates = [ + *(APP_ROOT.rglob("*.py")), + *(APP_ROOT / "templates").rglob("*.html"), + *(path for path in (APP_ROOT / "static" / "js").rglob("*.js") if "vendor" not in path.parts), + *(ROOT / "application" / "v2_ui" / "src").rglob("*.ts"), + *(ROOT / "application" / "v2_ui" / "src").rglob("*.tsx"), + ] + offenders = [ + path.relative_to(ROOT).as_posix() for path in candidates + if "source access could not be confirmed" in path.read_text(encoding="utf-8", errors="replace").lower() + ] + assert offenders == [] + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"])) diff --git a/functional_tests/test_analyze_artifact_phase7_rollout_rollback.py b/functional_tests/test_analyze_artifact_phase7_rollout_rollback.py index a673865b8..78004803d 100644 --- a/functional_tests/test_analyze_artifact_phase7_rollout_rollback.py +++ b/functional_tests/test_analyze_artifact_phase7_rollout_rollback.py @@ -2,8 +2,9 @@ # test_analyze_artifact_phase7_rollout_rollback.py """ Functional test for Analyze artifact Phase 7 rollout rollback controls. -Version: 0.250.177 +Version: 0.261.234 Implemented in: 0.250.177 +Planner stubs scoped to this module in: 0.261.234 This test ensures Phase 7 can stop new shared tabular parity assignments through a backend-only rollback state without exposing prompts, filenames, @@ -11,11 +12,15 @@ """ import ast +import importlib import sys import traceback import types +from contextlib import contextmanager from pathlib import Path +import pytest + from test_support.versioning import assert_app_version_at_least @@ -24,10 +29,11 @@ EXPORT_MODULE = APP_ROOT / "functions_tabular_generated_exports.py" SETTINGS_MODULE = APP_ROOT / "functions_settings.py" IMPLEMENTED_VERSION = "0.250.177" +PLANNER_MODULE = "functions_tabular_orchestration" sys.path.insert(0, str(APP_ROOT)) -def install_lightweight_planner_dependency_stubs(): +def lightweight_planner_dependency_stubs(): assistant_exports_module = types.ModuleType("functions_assistant_table_exports") assistant_exports_module.assistant_table_export_requested = ( lambda prompt: "csv" in str(prompt or "").lower() @@ -43,17 +49,39 @@ def get_requested_artifact_formats(prompt): lambda prompt: next(iter(get_requested_artifact_formats(prompt)), None) ) generated_exports_module.get_requested_structured_artifact_formats = get_requested_artifact_formats - sys.modules.setdefault("functions_assistant_table_exports", assistant_exports_module) - sys.modules.setdefault("functions_generated_file_exports", generated_exports_module) + return { + "functions_assistant_table_exports": assistant_exports_module, + "functions_generated_file_exports": generated_exports_module, + } -install_lightweight_planner_dependency_stubs() +@contextmanager +def lightweight_tabular_planner(): + """Import the planner, stubbing only dependencies that aren't imported yet. -from functions_tabular_orchestration import ( # noqa: E402 - build_tabular_parity_rollout_assignment, - normalize_tabular_parity_rollout_state, - orchestrate_tabular_request, -) + The stubs, and a planner bound to them, are removed from sys.modules on exit, so a later + test in the same process imports the real modules. + """ + stubs = { + name: module for name, module in lightweight_planner_dependency_stubs().items() + if name not in sys.modules + } + with pytest.MonkeyPatch.context() as patch: + for name, module in stubs.items(): + patch.setitem(sys.modules, name, module) + already_loaded = PLANNER_MODULE in sys.modules + planner = importlib.import_module(PLANNER_MODULE) + try: + yield planner + finally: + if stubs and not already_loaded: + sys.modules.pop(PLANNER_MODULE, None) + + +@pytest.fixture(scope="module") +def planner(): + with lightweight_tabular_planner() as module: + yield module def assert_equal(actual, expected, label): @@ -110,10 +138,12 @@ def safe_int(value, default=0, minimum=None, maximum=None): return namespace["_normalize_tabular_run_rollout_assignment"] -def test_rollout_state_normalization_and_assignment_reasons(): +def test_rollout_state_normalization_and_assignment_reasons(planner): """Rollout states must be deterministic, safe, and assignment-gating.""" print("Testing Phase 7 rollout state assignment gates...") assert_app_version_at_least(IMPLEMENTED_VERSION) + build_tabular_parity_rollout_assignment = planner.build_tabular_parity_rollout_assignment + normalize_tabular_parity_rollout_state = planner.normalize_tabular_parity_rollout_state base_settings = { "tabular_request_planner_mode": "active", @@ -163,7 +193,7 @@ def test_rollout_state_normalization_and_assignment_reasons(): assert_false("blob_path" in serialized_assignment, "no blob paths in assignment") -def test_rollback_state_declines_new_execution_without_calling_executor(): +def test_rollback_state_declines_new_execution_without_calling_executor(planner): """Rollback must stop new durable assignment before side effects.""" print("Testing Phase 7 rollback execution gate...") calls = [] @@ -176,7 +206,7 @@ def fake_durable_executor(plan, **execution_context): "task_type": plan["durable_task_type"], } - result = orchestrate_tabular_request( + result = planner.orchestrate_tabular_request( "Analyze every row and create a CSV file with one output row per source row.", [build_context()], action_mode="analyze", @@ -244,22 +274,31 @@ def test_rollout_state_is_backend_only_and_status_safe(): def run_tests(): - tests = [ - test_rollout_state_normalization_and_assignment_reasons, - test_rollback_state_declines_new_execution_without_calling_executor, - test_rollout_state_is_backend_only_and_status_safe, - ] results = [] - for test in tests: - print(f"\nRunning {test.__name__}...") - try: - test() - results.append(True) - print(f"PASS {test.__name__}") - except Exception as exc: - print(f"FAIL {test.__name__}: {exc}") - traceback.print_exc() - results.append(False) + with lightweight_tabular_planner() as planner_module: + for name, run in ( + ( + "test_rollout_state_normalization_and_assignment_reasons", + lambda: test_rollout_state_normalization_and_assignment_reasons(planner_module), + ), + ( + "test_rollback_state_declines_new_execution_without_calling_executor", + lambda: test_rollback_state_declines_new_execution_without_calling_executor(planner_module), + ), + ( + "test_rollout_state_is_backend_only_and_status_safe", + test_rollout_state_is_backend_only_and_status_safe, + ), + ): + print(f"\nRunning {name}...") + try: + run() + results.append(True) + print(f"PASS {name}") + except Exception as exc: + print(f"FAIL {name}: {exc}") + traceback.print_exc() + results.append(False) print(f"\nResults: {sum(results)}/{len(results)} tests passed") return all(results) diff --git a/functional_tests/test_analyze_backend_saved_integration.py b/functional_tests/test_analyze_backend_saved_integration.py index 6c98281e4..1bb405ca0 100644 --- a/functional_tests/test_analyze_backend_saved_integration.py +++ b/functional_tests/test_analyze_backend_saved_integration.py @@ -1,9 +1,10 @@ # test_analyze_backend_saved_integration.py """ Behavioral integration tests for Analyze presentation and saved-data chat reuse. -Version: 0.261.232 +Version: 0.261.234 Implemented in: 0.261.109 Container-only saved-result access covered in: 0.261.232 +Message family constants added to the chat harness in: 0.261.234 Real adapter, artifact, history, chat route and shared section-reader functions run against serialized storage, Flask requests and deterministic model doubles. @@ -456,6 +457,8 @@ def read_m365_conversation(user_id, conversation_id): "authorize_analysis_conversation": lambda *args: deepcopy(conversation), "AnalysisWorkUnitConflictError": result_storage.AnalysisWorkUnitConflictError, "AnalysisResultUnavailable": saved.AnalysisResultUnavailable, + "ANALYSIS_UNAVAILABLE_CONTAINER": access.ANALYSIS_UNAVAILABLE_CONTAINER, + "ANALYSIS_UNAVAILABLE_SAVED_RESULT": access.ANALYSIS_UNAVAILABLE_SAVED_RESULT, "load_saved_analysis_input": lambda user_id, context, **kwargs: saved.load_saved_analysis_input( user_id, context, **options, **kwargs, ), diff --git a/functional_tests/test_analyze_workflow_publication_integration.py b/functional_tests/test_analyze_workflow_publication_integration.py index b3a9b9286..f51ec796f 100644 --- a/functional_tests/test_analyze_workflow_publication_integration.py +++ b/functional_tests/test_analyze_workflow_publication_integration.py @@ -1,9 +1,10 @@ # test_analyze_workflow_publication_integration.py """ Analyze -> save -> reload -> explain -> optional publish in one workflow run. -Version: 0.261.231 +Version: 0.261.234 Implemented in: 0.261.109 Saved results stopped re-checking their sources in: 0.261.231 +Model limit patch retargeted in: 0.261.234 The native adapter, section contract, task sequence, model consumer, artifact authorization and publication receipt service are production code. Only native @@ -19,7 +20,7 @@ import pytest from test_analysis_artifact_publication import publication -from test_analyze_backend_saved_integration import budget, load_functions, saved +from test_analyze_backend_saved_integration import budget, load_functions, model_budget, saved from test_analyze_native_saved_integration import adapt, native_run from test_support.app_stubs import import_app_module from test_support.document_analysis import USER_ID @@ -69,10 +70,11 @@ def completion(**kwargs): ) model = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=completion))) - monkeypatch.setattr(budget, "resolve_model_token_limits", lambda *args, **kwargs: { + # The workflow context resolves each request's limits through resolve_model_token_budget. + monkeypatch.setattr(budget, "resolve_model_token_budget", lambda *args, **kwargs: model_budget({ "context_window_tokens": 1000000, "max_input_tokens": 1000000, "max_output_tokens": 2048, "tokenizer": None, "source": "configured", "model_id": "task-selected-model", "status": "known", - }) + })) def dispatch(workflow, settings, conversation_id, run_id, thought_tracker, url_access_context, **kwargs): if workflow["active_task"]["id"] == "analyze": @@ -280,10 +282,10 @@ def upload(**kwargs): def test_large_workflow_report_pages_records_but_publication_only_reads_manifests(sequence, monkeypatch): fixture = sequence seen = [] - monkeypatch.setattr(budget, "resolve_model_token_limits", lambda *args, **kwargs: { + monkeypatch.setattr(budget, "resolve_model_token_budget", lambda *args, **kwargs: model_budget({ "context_window_tokens": 16000, "max_input_tokens": 16000, "max_output_tokens": 2048, "tokenizer": None, "source": "configured", "model_id": "small-selected-model", "status": "known", - }) + })) def complete(**kwargs): fixture.model_calls.append(deepcopy(kwargs)) diff --git a/functional_tests/test_group_workflow_round_trip_preservation.py b/functional_tests/test_group_workflow_round_trip_preservation.py index 251a28e18..db0e3a4f3 100644 --- a/functional_tests/test_group_workflow_round_trip_preservation.py +++ b/functional_tests/test_group_workflow_round_trip_preservation.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional test for group workflow save round-trip preservation. -Version: 0.261.178 +Version: 0.261.234 Implemented in: 0.261.141 +Chat delivery contract loaded by the harness in: 0.261.234 This test ensures that existing group workflow definitions survive load, edit and save. A group workflow carrying alert rules, URL access, a Monitor File Sync trigger and a document action is @@ -298,11 +299,13 @@ def sanitize_file_sync_source(source): "functions_workflow_runtime_store": _module("functions_workflow_runtime_store"), **_document_analysis_seams(), } - # Load order follows the import graph: the alert normalizer imports the definitions module, and + # Load order follows the import graph: the alert normalizer imports the definitions module, the + # personal store imports the chat delivery contract, which imports the Microsoft 365 binding, and # the group store takes its member roles from the pure workflow policy module. real = ( "functions_workflow_alert_safety", "functions_workflow_definitions", "functions_workflow_alerts", - "functions_m365_workflow_binding", "functions_workflow_definition_store", "functions_document_actions", + "functions_m365_workflow_binding", "functions_workflow_chat_delivery", + "functions_workflow_definition_store", "functions_document_actions", "functions_workflow_limits", "functions_workflow_schedules", "functions_personal_workflows", "functions_group_workflow_policy", "functions_group_workflows", ) diff --git a/functional_tests/test_workflow_data_flow_execution.py b/functional_tests/test_workflow_data_flow_execution.py index 752e37f4a..e1832da57 100644 --- a/functional_tests/test_workflow_data_flow_execution.py +++ b/functional_tests/test_workflow_data_flow_execution.py @@ -1,9 +1,10 @@ # test_workflow_data_flow_execution.py """ Functional tests for native data-flow execution through real workflow functions. -Version: 0.261.231 +Version: 0.261.234 Implemented in: 0.261.108 Saved outputs stopped re-checking their sources in: 0.261.231 +Run entrypoint harness restored in: 0.261.234 Production Analyze, dispatch, immutable persistence/reload, binding, validation and source-lineage readers run together; only provider and source I/O are faked. @@ -163,13 +164,33 @@ def test_real_run_entrypoint_persists_the_deliverable_outcome(expected_count, al } saved_runs = [] activity = [] + + def unexpected_owner_lookup(*args, **kwargs): + raise AssertionError("The signed-in owner's request context is reused, not rebuilt from a profile lookup.") + + def read_saved_run(user_id, run_id): + # The run store returns exactly what the entrypoint saved for this owner, if anything. + return next(( + json.loads(json.dumps(record)) for record in reversed(saved_runs) + if record["id"] == run_id and record["user_id"] == user_id + ), None) + runner.update({ + # The owner starts this run from a signed-in request, so the entrypoint reuses that context. + "session": {"user": {"oid": "owner", "roles": ["User"], "name": "Owner"}}, + "read_user_settings_snapshot": unexpected_owner_lookup, + # The inventory workflow selects no agent, so it has no Microsoft 365 actions to authorize. + "workflow_m365_manifests": lambda workflow: ([], dict(workflow)), + "get_personal_workflow_run": read_saved_run, "WorkflowRunCancelledError": type("WorkflowRunCancelledError", (BaseException,), {}), "_save_workflow_run_record": lambda workflow, record: saved_runs.append(json.loads(json.dumps(record))) or record, "_execute_workflow_file_sync": lambda *args: None, "_ensure_workflow_conversation": lambda workflow: {"id": "conversation-inventory", "user_id": "owner"}, "_create_user_message": lambda *args: {"id": "user-message"}, - "_initialize_workflow_assistant_tracking": lambda *args: ("assistant-message", None), + # Like the real helper, a stored assistant message id is reused; a new run gets its own. + "_initialize_workflow_assistant_tracking": lambda *args, assistant_message_id=None: ( + assistant_message_id or "assistant-message", None, + ), "_prepare_workflow_url_access_context": lambda *args, **kwargs: {}, "_attach_workflow_url_access_result": lambda result, context: result, "_create_assistant_message": lambda *args, **kwargs: {"id": "assistant-message"}, diff --git a/functional_tests/test_workflow_draft_save_parity.py b/functional_tests/test_workflow_draft_save_parity.py index c6c7dde4e..11cb92239 100644 --- a/functional_tests/test_workflow_draft_save_parity.py +++ b/functional_tests/test_workflow_draft_save_parity.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional test for workflow save parity across the workflow draft service refactor. -Version: 0.261.202 +Version: 0.261.234 Implemented in: 0.261.202 +Chat delivery contract loaded by the harness in: 0.261.234 This test ensures that ``save_personal_workflow`` and ``save_group_workflow`` behave byte for byte as they did before each save was split into a write-free build step and a persist step. @@ -103,10 +104,12 @@ # A save imports these lazily; they are tracked so a call never leaves one installed afterwards. LAZY_MODULES = ("functions_workflow_execution", "functions_agent_delegation") # Real modules, in import-graph order. The structured-flow modules are loaded here because a version -# 3 save imports them lazily and the application folder is not on this test's import path. +# 3 save imports them lazily and the application folder is not on this test's import path. The +# personal store imports the chat delivery contract, which imports the Microsoft 365 binding. REAL_MODULES = ( "functions_workflow_alert_safety", "functions_workflow_definitions", "functions_workflow_alerts", - "functions_m365_workflow_binding", "functions_workflow_definition_store", "functions_document_actions", + "functions_m365_workflow_binding", "functions_workflow_chat_delivery", + "functions_workflow_definition_store", "functions_document_actions", "functions_workflow_limits", "functions_workflow_schedules", "functions_workflow_loop_schema", "functions_workflow_flow", "functions_workflow_identity", "functions_workflow_loop_runners", diff --git a/functional_tests/test_workflow_durable_routes.py b/functional_tests/test_workflow_durable_routes.py index 0c0ffdb33..b3542f97d 100644 --- a/functional_tests/test_workflow_durable_routes.py +++ b/functional_tests/test_workflow_durable_routes.py @@ -1,8 +1,9 @@ # test_workflow_durable_routes.py """ Functional tests for durable workflow HTTP control boundaries. -Version: 0.261.111 +Version: 0.261.234 Implemented in: 0.261.111 +Saved-record refusal wording covered in: 0.261.234 Production route helpers run in Flask with isolated runtime services. Decisions are scope-authorized and version/request-bound; native runs return queued202. @@ -21,6 +22,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "application" / "single_app")) # Application imports follow the worktree module-path setup. +from functions_analysis_access import AnalysisResultUnavailable from functions_workflow_runtime_store import RuntimeUnavailable, WorkflowRuntimeConflict @@ -50,6 +52,8 @@ def assert_role(*args, **kwargs): def status(workflow, run_id, **kwargs): if state.get("unavailable"): raise RuntimeUnavailable() + if state.get("saved_record_error"): + raise state["saved_record_error"] calls.append(("read", run_id, kwargs)) return runtime @@ -64,6 +68,7 @@ def decision(workflow, run_id, data, **kwargs): "CosmosResourceNotFoundError": CosmosResourceNotFoundError, "WorkflowRuntimeConflict": WorkflowRuntimeConflict, "RuntimeUnavailable": RuntimeUnavailable, + "AnalysisResultUnavailable": AnalysisResultUnavailable, "get_current_user_id": lambda: "owner", "get_personal_workflow": lambda user_id, workflow_id: workflow if workflow_id == workflow["id"] else None, "get_group_workflow": lambda group_id, workflow_id: workflow if workflow_id == workflow["id"] else None, @@ -137,10 +142,27 @@ def test_readonly_group_members_can_view_but_not_approve(runtime_api): def test_group_membership_is_checked_again_after_reload(runtime_api): client, state, calls = runtime_api state["allowed"] = False - assert client.get("/group/workflow/run/runtime").status_code == 403 + response = client.get("/group/workflow/run/runtime") + assert response.status_code == 403 + assert response.json == {"error": "Workflow progress is unavailable because current access could not be confirmed."} assert calls == [] +@pytest.mark.parametrize("code,expected", [ + ("analysis_lineage_invalid", "This saved result can't be used because something it depends on is missing or has changed."), + ("generated_artifact_source_unavailable", "The conversation, workflow or run that holds this result no longer exists"), +]) +def test_saved_record_failure_names_the_record_not_the_callers_access(runtime_api, code, expected): + client, state, calls = runtime_api + state["saved_record_error"] = AnalysisResultUnavailable(code) + response = client.get("/user/workflow/run/runtime") + assert response.status_code == 403 + assert response.json["error"].startswith("Workflow progress is unavailable. ") + assert expected in response.json["error"] + assert "access could not be confirmed" not in response.json["error"] + assert set(response.json) == {"error"} + + def test_runtime_decisions_cannot_inject_private_claim_fields(runtime_api): client, state, calls = runtime_api assert client.post("/user/workflow/run/runtime/decision", json={ diff --git a/functional_tests/test_workflow_task_result_handoff.py b/functional_tests/test_workflow_task_result_handoff.py index f4f9c3cca..744117f6c 100644 --- a/functional_tests/test_workflow_task_result_handoff.py +++ b/functional_tests/test_workflow_task_result_handoff.py @@ -1,8 +1,9 @@ # test_workflow_task_result_handoff.py """ Functional regression for workflow result production, persistence, and handoff. -Version: 0.261.122 +Version: 0.261.234 Implemented in: 0.261.106 +Harness dependencies restored in: 0.261.234 Fictional inventory records pass through the production document analysis, artifact presentation, task dispatch, and sequence implementations. Only @@ -27,8 +28,10 @@ from test_document_analysis_lossless_artifacts import build_window, load_module_functions from test_workflow_result_store import FakeBlobService, FakeCosmosContainer from test_support.workflow_results import workflow_result_helpers +from functions_orchestration_execution_policy import generated_file_publication_allowed from functions_workflow_alert_safety import sanitize_workflow_alert_record from functions_workflow_result_store import WorkflowResultStore +from functions_workflow_schedules import WORKFLOW_SCHEDULED_TRIGGER_TYPES, workflow_run_time_context from functions_workflow_results import ( build_workflow_task_result, load_workflow_task_input, @@ -166,6 +169,8 @@ def call(*args, **kwargs): "normalize_search_scope": lambda value: value or "personal", "PROVENANCE_FIELD": screening_access.PROVENANCE_FIELD, "ScreeningError": ScreeningError, + # The real dependency-free policy: no orchestration task restricts these runs' files. + "generated_file_publication_allowed": generated_file_publication_allowed, "guard_model_callable": screened_model, "assert_evidence_available": partial(screening_access.assert_evidence_available, metadata_reader=screening_metadata), }) @@ -216,6 +221,9 @@ def call(*args, **kwargs): "build_generated_file_output_guidance": lambda prompt: "", "upload_generated_document_for_current_user": unexpected_workspace_write, "queue_generated_document_processing": unexpected_workspace_write, + # Real, dependency-free schedule rules used when a run entrypoint prepares its workflow. + "WORKFLOW_SCHEDULED_TRIGGER_TYPES": WORKFLOW_SCHEDULED_TRIGGER_TYPES, + "workflow_run_time_context": workflow_run_time_context, }) runner.update({ "_result_reads": loaded_refs,