diff --git a/application/single_app/config.py b/application/single_app/config.py index 8096ab0bb..122ab0ac4 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -97,7 +97,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.117" +VERSION = "0.261.118" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/content_screening/access.py b/application/single_app/content_screening/access.py index baf9ce5bc..c04362fcb 100644 --- a/application/single_app/content_screening/access.py +++ b/application/single_app/content_screening/access.py @@ -64,6 +64,7 @@ "canonical_ref", "units_ref", "result_ref", "original_blob_path", "original_blob_container", "active_manifest_id", "active_content_manifest", PROVENANCE_FIELD, + "generated_artifact_publication_binding", "generated_artifact_publication_processing", }) @@ -768,7 +769,9 @@ def public_document_payload(document): if not isinstance(document, Mapping): return {} if SCREENING_FIELD not in document: - return deepcopy(dict(document)) + return {key: deepcopy(value) for key, value in document.items() if key not in { + "generated_artifact_publication_binding", "generated_artifact_publication_processing", + }} try: _require_available_metadata(document) config = import_module("config") @@ -776,9 +779,15 @@ def public_document_payload(document): available = True except (ScreeningError, AttributeError): available = False + public_fields = HELD_PUBLIC_FIELDS + if document.get("generated_artifact_publication_binding"): + public_fields = public_fields | { + "generated_artifact_promotion_status", "generated_artifact_requested_by_user_id", + "generated_artifact_requested_by_display_name", "generated_artifact_requested_at", + } payload = { key: deepcopy(value) for key, value in document.items() - if (available or key in HELD_PUBLIC_FIELDS) + if (available or key in public_fields) and key not in PRIVATE_DOCUMENT_FIELDS and key != SCREENING_FIELD and not key.startswith("_") and not key.startswith("screening_") } @@ -878,6 +887,7 @@ def reject_screening_fields(payload): for key, value in payload.items(): normalized = str(key).replace("_", "").replace("-", "").lower() if normalized.startswith(("contentscreening", "screening")) or normalized in { + "generatedartifactpublicationbinding", "generatedartifactpublicationprocessing", "availabilitygeneration", "activecontentmanifest", "activemanifestid", "canonicalref", "unitsref", "resultref", "sourceref", "scanid", "reviewid", "contentfingerprint", "sourcerevision", diff --git a/application/single_app/functions_artifact_publication.py b/application/single_app/functions_artifact_publication.py index b9be45076..e71855b12 100644 --- a/application/single_app/functions_artifact_publication.py +++ b/application/single_app/functions_artifact_publication.py @@ -21,6 +21,10 @@ cosmos_user_documents_container, ) from functions_appinsights import log_event +from functions_artifact_publication_readiness import ( + PUBLICATION_BINDING, inspect_publication_readiness, publication_binding_matches, + publication_handoff_observed, publication_processing_observation, public_publication_status, +) from functions_collaboration import build_conversation_participation_context from functions_documents import allowed_file, create_document, update_document from functions_generated_file_approvals import assert_generated_file_approval_allows_download @@ -127,6 +131,13 @@ def _receipt_change(artifact, key, change): updated = change(receipt) if updated is None: return receipt, False + if updated.get("completion_policy") and any( + other_key != key and other.get("request_id") == updated.get("request_id") + for other_key, other in receipts.items() + ): + raise ValueError("This publication request is already bound to different artifact bytes.") + if receipt is None and len(receipts) >= MAX_ARTIFACT_PUBLICATION_REQUESTS: + raise ValueError("This artifact has reached its publication request limit.") if not current.get("_etag"): raise RuntimeError("Conditional publication persistence is unavailable.") current = deepcopy(current) @@ -195,8 +206,10 @@ def _notification_exists(receipt, notification_type): ))) -def _notify_once(artifact, receipt, stage, notification_type, create): +def _notify_once(artifact, receipt, stage, notification_type, create, *, before=None): if _stage(artifact, receipt, stage): + if before is not None: + before() try: if create(): _stage(artifact, receipt, stage, complete=True) @@ -210,6 +223,8 @@ def _notify_once(artifact, receipt, stage, notification_type, create): def _publication_response(artifact, receipt, container): receipt, _ = _receipt_change(artifact, receipt["id"], lambda current: None) document = _destination_document(container, receipt) + if receipt.get("completion_policy"): + return _completion_response(receipt, document) scope = receipt["destination"]["workspace_scope"] stages = receipt.get("stages") or {} required = ["create", "prepare", "queue"] if scope == "personal" else [ @@ -242,8 +257,101 @@ def _publication_response(artifact, receipt, container): } +def _completion_response(receipt, document): + scope = receipt["destination"]["workspace_scope"] + required = ["create", "prepare", "queue"] if scope == "personal" else [ + "create", "prepare", "workspace_notification", "submitter_notification", + ] + decision = (receipt.get("decision") or {}).get("choice", "pending") + if decision not in {"pending", "approved", "rejected", "cancelled"}: + raise ValueError("The saved publication decision is unsupported.") + if decision == "approved": + required.append("approval_queue") + unresolved = [name for name in required if receipt.get("stages", {}).get(name) != "complete"] + processing = publication_processing_observation(receipt, document) + if processing == "not_started" and receipt.get("stages", {}).get("queue") == "complete": + processing = "queued" + status = { + "version": 1, "id": receipt["id"], "document_id": receipt["document_id"], + "document_version": receipt.get("document_version"), "destination": deepcopy(receipt["destination"]), + "completion_policy": receipt["completion_policy"], "policy_satisfied": False, + "state": "uncertain", "submission": "uncertain" if unresolved else "confirmed", + "approval": "not_required" if scope == "personal" else decision, + "processing": processing, "screening": "not_required", "index": "pending", + "reason_code": "", "retryable": False, "unresolved_stages": unresolved, + } + facts = {} + if publication_binding_matches(receipt, document): + facts = inspect_publication_readiness(receipt, document, check_index=False) + status.update({key: facts[key] for key in ("processing", "screening", "index")}) + if status["processing"] == "not_started" and any( + receipt.get("stages", {}).get(stage) == "complete" for stage in ("queue", "approval_queue") + ): + status["processing"] = "queued" + if decision in {"rejected", "cancelled"}: + status.update(state=decision, reason_code=f"publication_{decision}") + elif document is None and receipt.get("stages", {}).get("create") != "complete": + status.update(reason_code="publication_effect_uncertain", retryable=True) + elif document is None: + status.update(state="unavailable", reason_code="publication_destination_unavailable") + elif not publication_binding_matches(receipt, document): + status.update(state="content_changed", reason_code="publication_revision_changed") + elif document.get("generated_artifact_promotion_status") == "approval_failed" and receipt.get("stages", {}).get("approval_queue") != "complete": + status.update(state="approval_failed", approval="failed", reason_code="publication_approval_failed", retryable=True) + elif unresolved: + status.update(reason_code="publication_effect_uncertain", retryable=True) + elif facts.get("reason_code") == "publication_content_changed": + status.update(state="content_changed", reason_code="publication_content_changed") + elif status["processing"] == "failed": + status.update(state="processing_failed", reason_code="publication_processing_failed", retryable=True) + elif receipt["completion_policy"] == "submitted": + status.update(state="submitted", policy_satisfied=True) + elif status["approval"] == "pending": + status["state"] = "waiting_approval" + elif receipt["completion_policy"] == "approved": + status.update(state="approved", policy_satisfied=True) + else: + status.update(inspect_publication_readiness(receipt, document)) + reason = status["reason_code"] + if reason: + status.update( + state="content_changed" if reason in {"publication_content_changed", "publication_revision_changed"} else + "processing_failed" if reason == "publication_processing_failed" else "unavailable", + retryable=reason in {"publication_processing_failed", "publication_screening_unavailable"}, + ) + elif status["index"] == "ready": + status.update(state="indexed_ready", policy_satisfied=True) + else: + status["state"] = ( + "waiting_screening" if status["screening"] in {"pending", "held"} else + "waiting_index" if status["processing"] == "complete" else "waiting_processing" + ) + messages = { + "submitted": "The existing artifact was submitted. Approval and index readiness were not requested.", + "approved": "Publication approval is satisfied. Index readiness was not requested.", + "indexed_ready": "The original artifact's exact destination revision is indexed and available.", + "waiting_approval": "Waiting for destination approval in the existing workspace review.", + "waiting_processing": "Waiting for the existing destination document to finish processing.", + "waiting_screening": "Waiting for the destination's content screening and review.", + "waiting_index": "Waiting for the complete destination revision to become visible in the index.", + "uncertain": "An existing publication effect could not be confirmed. Check the same receipt; do not create another copy.", + "rejected": "The destination rejected this publication. Cancel this run before starting a new request.", + "cancelled": "The destination publication request was cancelled.", + "approval_failed": "Destination approval could not finish processing. Check the existing request.", + "processing_failed": "The existing destination document could not finish processing.", + "unavailable": "The exact destination or its readiness proof is unavailable. No replacement was published.", + "content_changed": "The destination content or revision changed. The original publication policy was not satisfied.", + } + return { + "message": messages[status["state"]], **receipt["destination"], "approval_required": scope != "personal", + "document": {"id": receipt["document_id"], "file_name": receipt["file_name"]}, + "publication": public_publication_status(status), + } + + def publish_generated_chat_artifact_for_user( user_id, *, conversation_id, message_id, destination, request_id, requester_display_name="", file_name="", + completion_policy=None, source_receipt=None, execution_check=None, ): """Copy or request approval once; uncertain external work is never blindly repeated.""" user_id = _text(user_id, "Acting user") @@ -252,8 +360,17 @@ def publish_generated_chat_artifact_for_user( user_id, _text(conversation_id, "Conversation id"), _text(message_id, "Artifact message id"), ) destination, workspace_name, container = _authorize_destination(user_id, destination) + if completion_policy is not None: + # Kept at this opt-in boundary so ordinary artifact publication needs no workflow compiler. + from functions_workflow_definitions import normalize_publication_completion_policy + + completion_policy = normalize_publication_completion_policy(completion_policy) + if not isinstance(source_receipt, dict) or (source_receipt.get("producer") or {}).get("kind") == "chat": + raise ValueError("Publication completion needs an exact saved workflow source receipt.") def reauthorize(): + if execution_check is not None: + execution_check() current = _authorize_artifact(user_id, artifact["conversation_id"], artifact["id"]) if _artifact_identity(current) != _artifact_identity(artifact): raise ValueError("The generated artifact changed before publication.") @@ -290,16 +407,28 @@ def reauthorize(): "content_sha256": content_sha256, "document_id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"simplechat-publication:{key}")), "file_name": name, "created_at": datetime.now(timezone.utc).isoformat(), "stages": {}, } + if completion_policy is not None: + receipt.update( + completion_policy=completion_policy, source_receipt=deepcopy(source_receipt), + artifact_reference={"conversation_id": conversation_id, "artifact_message_id": message_id}, + source_identity=_artifact_identity(artifact), + ) if len(metadata.get(RECEIPTS_FIELD) or {}) >= MAX_ARTIFACT_PUBLICATION_REQUESTS and key not in metadata[RECEIPTS_FIELD]: raise ValueError("This artifact has reached its publication request limit.") reauthorize() receipt, _ = _receipt_change(artifact, key, lambda current: None if current else receipt) + if receipt.get("completion_policy") != completion_policy or completion_policy is not None and receipt.get("source_receipt") != source_receipt: + raise ValueError("The publication request is already bound to different inputs or a different policy.") document = _destination_document(container, receipt) + if completion_policy and (receipt.get("decision") or {}).get("choice") in {"rejected", "cancelled"}: + return _completion_response(receipt, document) scope_args = {field: value for field, value in destination.items() if field != "workspace_scope"} scope = destination["workspace_scope"] if document is None: reauthorize() if document is None and _stage(artifact, receipt, "create"): + if completion_policy: + reauthorize() try: create_document( file_name=name, user_id=user_id, document_id=receipt["document_id"], num_file_chunks=0, @@ -311,10 +440,26 @@ def reauthorize(): if document is None: return _publication_response(artifact, receipt, container) _stage(artifact, receipt, "create", complete=True) + if completion_policy and "document_version" not in receipt: + if type(document.get("version")) is not int or document["version"] < 1: + raise ValueError("The publication destination has no valid native revision.") + reauthorize() + receipt, _ = _receipt_change( + artifact, key, lambda current: {**current, "document_version": document["version"]} + if "document_version" not in current else None, + ) if not document.get("generated_artifact_publication_receipt_id"): reauthorize() if not document.get("generated_artifact_publication_receipt_id") and _stage(artifact, receipt, "prepare"): + if completion_policy: + reauthorize() updates = {"generated_artifact_publication_receipt_id": key} + if completion_policy: + updates[PUBLICATION_BINDING] = { + "version": 1, "receipt_id": key, "document_version": receipt["document_version"], + "content_sha256": content_sha256, "conversation_id": conversation_id, + "artifact_message_id": message_id, + } if scope != "personal": updates.update( generated_artifact_promotion_status="pending_approval", @@ -347,6 +492,8 @@ def reauthorize(): raise ValueError("The generated artifact bytes changed.") reauthorize() if _stage(artifact, receipt, "queue"): + if completion_policy: + reauthorize() try: queue_generated_document_processing( document_id=receipt["document_id"], owner_user_id=user_id, @@ -356,7 +503,10 @@ def reauthorize(): invalidate_personal_search_cache(user_id) except (AzureError, OSError, RuntimeError) as exc: _log_uncertain("queue", exc) - elif document.get("status") not in {None, "", "Queued for processing"}: + elif ( + publication_handoff_observed(receipt, document) + if completion_policy else document.get("status") not in {None, "", "Queued for processing"} + ): _stage(artifact, receipt, "queue", complete=True) elif document.get("generated_artifact_promotion_status") == "pending_approval": reauthorize() @@ -368,13 +518,19 @@ def reauthorize(): "publication_receipt_id": key, "conversation_id": conversation_id, "message_id": message_id, } notify_workspace = create_group_notification if scope == "group" else create_public_workspace_notification + def workspace_notice(): + kwargs = { + "notification_type": "approval_request_pending", "title": "Approval required: generated artifact", + "message": f"{requester_display_name or 'A workspace member'} requested approval for {name} in {workspace_name}.", + "link_url": link_url, "link_context": link_context, "metadata": notification_metadata, + } + if completion_policy: + return create_notification(**scope_args, **kwargs, idempotency_key=f"publication:{key}:workspace") + return notify_workspace(destination[target_field], kwargs.pop("notification_type"), kwargs.pop("title"), kwargs.pop("message"), **kwargs) + _notify_once( artifact, receipt, "workspace_notification", "approval_request_pending", - lambda: notify_workspace( - destination[target_field], "approval_request_pending", "Approval required: generated artifact", - f"{requester_display_name or 'A workspace member'} requested approval for {name} in {workspace_name}.", - link_url=link_url, link_context=link_context, metadata=notification_metadata, - ), + workspace_notice, before=reauthorize if completion_policy else None, ) reauthorize() _notify_once( @@ -384,14 +540,20 @@ def reauthorize(): title="Generated artifact submitted for approval", message=f"{name} is waiting for approval in {workspace_name}.", link_url=link_url, link_context=link_context, metadata=notification_metadata, + **({"idempotency_key": f"publication:{key}:submitter"} if completion_policy else {}), ), + before=reauthorize if completion_policy else None, ) if scope == "group": invalidate_group_search_cache(destination["group_id"]) + if completion_policy: + reauthorize() return _publication_response(artifact, receipt, container) -def publish_workflow_analysis_artifact(user_id, *, publication, artifact_reference, request_id): +def publish_workflow_analysis_artifact( + user_id, *, publication, artifact_reference, request_id, source_receipt=None, execution_check=None, +): """Dispatch only a configured publication task using an actual upstream artifact address.""" if publication is None: return {"reply": "", "execution_status": "skipped", "publication": {"state": "not_requested"}, "model_calls": 0} @@ -411,13 +573,271 @@ def publish_workflow_analysis_artifact(user_id, *, publication, artifact_referen output_format = "md" if output_format != publication["artifact_format"]: raise ValueError("The requested format is not available. Publish an existing upstream artifact.") + if "completion_policy" in publication: + producer = metadata.get("analysis_producer") or {} + if ( + producer.get("kind") != "workflow" or not producer.get("execution_id") + or not isinstance(source_receipt, dict) + or source_receipt.get("producer") != {key: value for key, value in producer.items() if key != "kind"} + or not isinstance(source_receipt.get("result_ref"), dict) + or not isinstance(source_receipt.get("output_ref"), dict) + or not source_receipt.get("output_name") + ): + raise ValueError("Publication completion needs the exact native workflow result and attempt.") result = publish_generated_chat_artifact_for_user( user_id, conversation_id=conversation_id, message_id=message_id, - destination={key: value for key, value in publication.items() if key != "artifact_format"}, + destination={key: value for key, value in publication.items() if key not in {"artifact_format", "completion_policy"}}, request_id=request_id, + completion_policy=publication.get("completion_policy"), source_receipt=source_receipt, + execution_check=execution_check, ) - return { + response = { "reply": result["message"], "publication": result["publication"], "model_calls": 0, "execution_status": "blocked" if result["publication"]["state"] in {"uncertain", "approval_failed"} else "succeeded", "authoritative_result": {"kind": "json", "value": {"publication": result["publication"]}}, } + if "completion_policy" in publication: + response["execution_status"] = "succeeded" if result["publication"]["policy_satisfied"] else "pending" + response["_publication_request"] = { + "publication": deepcopy(publication), "artifact_reference": deepcopy(artifact_reference), + "request_id": request_id, "receipt_id": result["publication"]["id"], + "source_receipt": deepcopy(source_receipt), + } + return response + + +def read_workflow_artifact_publication( + user_id, request, *, reconcile=False, execution_check=None, authorization_only=False, +): + """Observe one exact receipt. Only the leased workflow may reconcile stage acknowledgements.""" + publication = normalize_workflow_publication(request["publication"]) + address = request["artifact_reference"] + artifact = _authorize_artifact(user_id, address["conversation_id"], address["artifact_message_id"]) + receipt, _ = _receipt_change(artifact, request["receipt_id"], lambda current: None) + if not receipt or any(receipt.get(name) != expected for name, expected in ( + ("actor_user_id", user_id), ("request_id", request["request_id"]), + ("completion_policy", publication.get("completion_policy")), ("source_receipt", request["source_receipt"]), + ("artifact_reference", {key: address[key] for key in ("conversation_id", "artifact_message_id")}), + )): + raise PermissionError("The publication receipt does not match this workflow request.") + destination = {key: value for key, value in publication.items() if key not in {"artifact_format", "completion_policy"}} + if ( + receipt["destination"] != destination or receipt.get("source_identity") != _artifact_identity(artifact) + or address.get("producer") != (artifact.get("metadata") or {}).get("analysis_producer") + ): + raise PermissionError("The publication receipt belongs to a different producer or destination.") + _, _, container = _authorize_destination(user_id, destination) + document = _destination_document(container, receipt) + if authorization_only: + # A fulfilled checkpoint is historical; later deletion must not rewrite it. + if reconcile or document is not None and not publication_binding_matches(receipt, document): + raise PermissionError("The completed publication destination could not be confirmed.") + if execution_check is not None: + execution_check() + return None + if reconcile: + if execution_check is None: + raise ValueError("Publication reconciliation requires the current workflow write fence.") + execution_check() + confirmed = [] + if document: + confirmed.append("create") + if publication_binding_matches(receipt, document): + confirmed.append("prepare") + if publication_handoff_observed(receipt, document): + confirmed.append("queue") + if (receipt.get("decision") or {}).get("choice") == "approved": + confirmed.append("approval_queue") + for stage, notification in ( + ("workspace_notification", "approval_request_pending"), + ("submitter_notification", "approval_request_pending_submitter"), + ): + if receipt.get("stages", {}).get(stage) == "started" and _notification_exists(receipt, notification): + confirmed.append(stage) + for stage in confirmed: + if receipt.get("stages", {}).get(stage) == "started": + execution_check() + _stage(artifact, receipt, stage, complete=True) + receipt, _ = _receipt_change(artifact, receipt["id"], lambda current: None) + result = _completion_response(receipt, document) + _authorize_artifact(user_id, address["conversation_id"], address["artifact_message_id"]) + _authorize_destination(user_id, destination) + if execution_check is not None: + execution_check() + return result + + +def _replace_publication_destination(container, receipt, updates): + for _ in range(8): + document = _destination_document(container, receipt) + if not publication_binding_matches(receipt, document): + raise ValueError("The publication destination revision is unavailable.") + try: + container.replace_item( + item=document["id"], body={**document, **updates}, etag=document["_etag"], + match_condition=MatchConditions.IfNotModified, + ) + return + except CosmosHttpResponseError as exc: + if exc.status_code != 412: + raise + raise RuntimeError("The publication destination is busy.") + + +def authorize_publication_status_read(user_id, status, *, actor_user_id): + """A workflow viewer does not inherit the publisher's destination permissions.""" + destination = status["destination"] + scope = destination["workspace_scope"] + if scope == "personal": + if user_id != actor_user_id: + raise PermissionError("This publication destination is private to its requester.") + container = cosmos_user_documents_container + elif scope == "group": + assert_group_role(user_id, destination["group_id"], allowed_roles=("Owner", "Admin", "DocumentManager", "User")) + container = cosmos_group_documents_container + elif scope == "public" and find_public_workspace_by_id(destination["public_workspace_id"]): + container = cosmos_public_documents_container + else: + raise PermissionError("Publication destination access could not be confirmed.") + try: + document = container.read_item(item=status["document_id"], partition_key=status["document_id"]) + except CosmosResourceNotFoundError: + return + field = {"personal": "user_id", "group": "group_id", "public": "public_workspace_id"}[scope] + if ( + document.get(field) != (actor_user_id if scope == "personal" else destination[field]) + or document.get("generated_artifact_publication_receipt_id") != status["id"] + ): + raise PermissionError("Publication destination access could not be confirmed.") + + +def decide_artifact_publication(user_id, document, choice): + """Use the destination's existing review role, while retaining a durable receipt outcome.""" + try: + return _decide_artifact_publication(user_id, document, choice) + except PermissionError as exc: + _log_uncertain("destination_authorization", exc) + raise PermissionError("Current publication source or destination access could not be confirmed.") from exc + except (AzureError, OSError, RuntimeError) as exc: + _log_uncertain("destination_decision", exc) + raise RuntimeError("The publication decision could not be fully confirmed. Refresh the existing request.") from exc + + +def _decide_artifact_publication(user_id, document, choice): + if choice not in {"approved", "rejected", "cancelled"}: + raise ValueError("Invalid publication decision.") + roles = ("Owner", "Admin", "DocumentManager", "User") if choice == "cancelled" else ("Owner", "Admin", "DocumentManager") + if document.get("group_id"): + assert_group_role(user_id, document["group_id"], allowed_roles=roles) + elif document.get("public_workspace_id"): + workspace = find_public_workspace_by_id(document["public_workspace_id"]) + if not workspace or get_user_role_in_public_workspace(workspace, user_id) not in roles: + raise PermissionError("You cannot decide this publication request.") + else: + raise ValueError("Personal publication does not require workspace approval.") + binding = document.get(PUBLICATION_BINDING) or {} + artifact = cosmos_messages_container.read_item( + item=binding["artifact_message_id"], partition_key=binding["conversation_id"], + ) + receipt, _ = _receipt_change(artifact, binding["receipt_id"], lambda current: None) + if not receipt or not publication_binding_matches(receipt, document): + raise ValueError("The publication decision does not match its destination.") + destination = receipt["destination"] + scope = destination["workspace_scope"] + if scope not in {"group", "public"}: + raise ValueError("Personal publication does not require workspace approval.") + if ( + scope == "group" and destination["group_id"] != document.get("group_id") + or scope == "public" and destination["public_workspace_id"] != document.get("public_workspace_id") + or receipt["document_id"] != document["id"] + ): + raise PermissionError("The publication belongs to a different workspace.") + def authorize_decision(): + if scope == "group": + assert_group_role(user_id, destination["group_id"], allowed_roles=roles) + else: + workspace = find_public_workspace_by_id(destination["public_workspace_id"]) + if not workspace or get_user_role_in_public_workspace(workspace, user_id) not in roles: + raise PermissionError("You cannot decide this publication request.") + if choice == "cancelled" and receipt["actor_user_id"] != user_id: + raise PermissionError("Only the publication requester can cancel this request.") + + authorize_decision() + source_bytes = None + if choice == "approved": + if receipt.get("source_identity") != _artifact_identity(artifact): + raise ValueError("The original publication artifact changed.") + _authorize_artifact(receipt["actor_user_id"], artifact["conversation_id"], artifact["id"]) + _authorize_destination(receipt["actor_user_id"], destination) + source_bytes = download_blob_content(artifact["blob_container"], artifact["blob_path"]) + if hashlib.sha256(source_bytes).hexdigest() != receipt["content_sha256"]: + raise ValueError("The original publication artifact changed.") + _authorize_artifact(receipt["actor_user_id"], artifact["conversation_id"], artifact["id"]) + _authorize_destination(receipt["actor_user_id"], destination) + authorize_decision() + + def record_decision(current): + previous = current.get("decision") + if previous: + if previous.get("choice") != choice: + raise ValueError("A different publication decision already committed.") + return None + current["decision"] = { + "choice": choice, "actor_user_id": user_id, "decided_at": datetime.now(timezone.utc).isoformat(), + } + return current + + receipt, _ = _receipt_change(artifact, receipt["id"], record_decision) + container = cosmos_group_documents_container if scope == "group" else cosmos_public_documents_container + scope_args = {key: destination[key] for key in ("group_id", "public_workspace_id") if key in destination} + if choice == "approved": + authorize_decision() + _replace_publication_destination(container, receipt, { + "generated_artifact_promotion_status": "approved", + "generated_artifact_approved_at": receipt["decision"]["decided_at"], + "generated_artifact_approved_by_user_id": receipt["decision"]["actor_user_id"], + }) + if _stage(artifact, receipt, "approval_queue"): + try: + _authorize_artifact(receipt["actor_user_id"], artifact["conversation_id"], artifact["id"]) + _authorize_destination(receipt["actor_user_id"], destination) + authorize_decision() + queue_generated_document_processing( + document_id=receipt["document_id"], owner_user_id=receipt["actor_user_id"], + normalized_file_name=receipt["file_name"], file_content_bytes=source_bytes, **scope_args, + ) + _stage(artifact, receipt, "approval_queue", complete=True) + except (AzureError, OSError, RuntimeError, ValueError, PermissionError) as exc: + _log_uncertain("approval_queue", exc) + _replace_publication_destination(container, receipt, { + "generated_artifact_promotion_status": "approval_failed", + }) + raise RuntimeError("Approval was recorded, but its existing processing handoff needs reconciliation.") from exc + elif publication_handoff_observed(receipt, _destination_document(container, receipt)): + _stage(artifact, receipt, "approval_queue", complete=True) + else: + # The durable decision survives removal of the pending destination shell. + from functions_documents import delete_document_revision + + authorize_decision() + if _destination_document(container, receipt): + delete_document_revision( + user_id=user_id, document_id=receipt["document_id"], delete_mode="current_only", **scope_args, + ) + if choice != "cancelled": + notification_type = "approval_request_approved" if choice == "approved" else "approval_request_denied" + _notify_once( + artifact, receipt, "decision_notification", notification_type, + lambda: create_notification( + user_id=receipt["actor_user_id"], notification_type=notification_type, + title="Generated artifact approved" if choice == "approved" else "Generated artifact denied", + message="The destination approved your generated artifact." if choice == "approved" else + "The destination rejected your generated artifact.", + link_url="/group_workspaces" if scope == "group" else "/public_workspaces", + link_context={"workspace_type": scope, **scope_args, "document_id": receipt["document_id"]}, + metadata={**scope_args, "document_id": receipt["document_id"], "publication_receipt_id": receipt["id"], + "request_type": "generated_artifact_promotion"}, + idempotency_key=f"publication:{receipt['id']}:decision", + ), + ) + return {"message": f"Publication {choice}.", "document_id": receipt["document_id"]} diff --git a/application/single_app/functions_artifact_publication_readiness.py b/application/single_app/functions_artifact_publication_readiness.py new file mode 100644 index 000000000..e5235178d --- /dev/null +++ b/application/single_app/functions_artifact_publication_readiness.py @@ -0,0 +1,270 @@ +# functions_artifact_publication_readiness.py +"""Receipt-bound observations of the existing document ingestion lifecycle.""" + +from copy import deepcopy +from datetime import datetime, timezone +import hashlib +from pathlib import Path + +from azure.core import MatchConditions +from azure.cosmos.exceptions import CosmosHttpResponseError + +from content_screening.access import assert_document_available +from content_screening.contracts import AVAILABLE_STATES, HELD_STATES, SCREENING_FIELD, DocumentHeldError + + +PUBLICATION_BINDING = "generated_artifact_publication_binding" +PUBLICATION_PROCESSING = "generated_artifact_publication_processing" +PUBLICATION_STATUS_FIELDS = ( + "version", "id", "document_id", "document_version", "destination", "completion_policy", + "policy_satisfied", "state", "submission", "approval", "processing", "screening", "index", + "reason_code", "retryable", "unresolved_stages", +) + + +def public_publication_status(value): + if not isinstance(value, dict) or value.get("version") != 1: + raise ValueError("The publication completion status is unavailable.") + projected = {key: deepcopy(value[key]) for key in PUBLICATION_STATUS_FIELDS if key in value} + destination = value.get("destination") + if not isinstance(destination, dict) or destination.get("workspace_scope") not in {"personal", "group", "public"}: + raise ValueError("The publication destination is unavailable.") + projected["destination"] = {key: destination[key] for key in ( + "workspace_scope", "group_id", "public_workspace_id", + ) if key in destination} + return projected + + +def _container(document): + # Ingestion imports this module; resolve app clients only at the operation boundary. + import config + + return ( + config.cosmos_public_documents_container if document.get("public_workspace_id") else + config.cosmos_group_documents_container if document.get("group_id") else + config.cosmos_user_documents_container + ) + + +def _same_document(current, document): + return all(current.get(key) == document.get(key) for key in ( + "id", "user_id", "group_id", "public_workspace_id", "version", PUBLICATION_BINDING, + )) + + +def _current_revision(document): + return document.get("is_current_version") is not False and document.get("search_visibility_state") != "archived" + + +def _processing_change(document, change): + container = _container(document) + for _ in range(8): + current = container.read_item(item=document["id"], partition_key=document["id"]) + if not _same_document(current, document) or not current.get("_etag"): + raise ValueError("The publication destination revision changed.") + replacement = change(deepcopy(current)) + if replacement is None: + return False + try: + container.replace_item( + item=current["id"], body=replacement, etag=current["_etag"], + match_condition=MatchConditions.IfNotModified, + ) + return True + except CosmosHttpResponseError as exc: + if exc.status_code != 412: + raise + raise RuntimeError("The publication processing checkpoint is busy.") + + +def begin_publication_processing(document, source_path): + """A duplicate native dispatch cannot start another ingestion of this receipt.""" + binding = (document or {}).get(PUBLICATION_BINDING) + if not binding: + return True + digest = hashlib.sha256() + with Path(source_path).open("rb") as source: + for block in iter(lambda: source.read(64 * 1024), b""): + digest.update(block) + if binding.get("version") != 1 or binding.get("document_version") != document.get("version"): + raise ValueError("The publication destination revision changed.") + if binding.get("content_sha256") != digest.hexdigest(): + def changed_input(current): + if current.get(PUBLICATION_PROCESSING): + return None + current[PUBLICATION_PROCESSING] = { + "binding": deepcopy(binding), "state": "failed", "reason_code": "publication_content_changed", + } + return current + + _processing_change(document, changed_input) + raise ValueError("The publication input no longer matches the original artifact.") + + def claim(current): + previous = current.get(PUBLICATION_PROCESSING) + if previous: + if previous.get("binding") != binding: + raise ValueError("The publication processing identity changed.") + if previous.get("state") == "complete": + return None + raise RuntimeError("This publication already has a native processing attempt. Reconcile it before retrying.") + current[PUBLICATION_PROCESSING] = { + "binding": deepcopy(binding), "state": "running", + "started_at": datetime.now(timezone.utc).isoformat(), + } + return current + + return _processing_change(document, claim) + + +def finish_publication_processing(document, *, indexed_chunks=None, failed=False): + """Native ingestion owns this evidence, not the workflow polling its result.""" + binding = (document or {}).get(PUBLICATION_BINDING) + if not binding: + return + if not failed and (type(indexed_chunks) is not int or indexed_chunks < 0): + raise ValueError("The native publication chunk count is invalid.") + + def finish(current): + previous = current.get(PUBLICATION_PROCESSING) or {} + if previous.get("binding") != binding or previous.get("state") not in {"running", "complete", "failed"}: + raise ValueError("The publication has no matching native processing checkpoint.") + state = "failed" if failed else "complete" + if previous.get("state") in {"complete", "failed"}: + if previous["state"] != state or not failed and previous.get("indexed_chunks") != indexed_chunks: + raise ValueError("The native publication outcome already committed.") + return None + current[PUBLICATION_PROCESSING] = { + **previous, "state": state, "indexed_chunks": indexed_chunks, + "completed_at": datetime.now(timezone.utc).isoformat(), + } + return current + + _processing_change(document, finish) + + +def publication_binding_matches(receipt, document): + binding = (document or {}).get(PUBLICATION_BINDING) or {} + return bool( + document and binding.get("version") == 1 + and binding.get("receipt_id") == receipt["id"] + and binding.get("content_sha256") == receipt["content_sha256"] + and type(binding.get("document_version")) is int + and binding["document_version"] == document.get("version") == receipt.get("document_version") + and document.get("generated_artifact_publication_receipt_id") == receipt["id"] + and binding.get("conversation_id") == receipt["artifact_reference"]["conversation_id"] + and binding.get("artifact_message_id") == receipt["artifact_reference"]["artifact_message_id"] + ) + + +def publication_processing_observation(receipt, document): + if not publication_binding_matches(receipt, document): + return "unavailable" + evidence = document.get(PUBLICATION_PROCESSING) or {} + if evidence.get("binding") != document[PUBLICATION_BINDING]: + return "not_started" + return evidence.get("state") if evidence.get("state") in {"running", "complete", "failed"} else "unavailable" + + +def publication_handoff_observed(receipt, document): + if publication_processing_observation(receipt, document) in {"running", "complete", "failed"}: + return True + if (document or {}).get(SCREENING_FIELD): + observed = inspect_publication_readiness(receipt, document, check_index=False) + return ( + observed["processing"] == "complete" and observed["screening"] == "available" + and not observed.get("reason_code") + ) + return False + + +def _index_count(receipt, document): + # Reuse the native scoped Search client. No ranked search or filename matching. + from functions_documents import _get_search_client + + destination = receipt["destination"] + scope = destination["workspace_scope"] + field = {"personal": "user_id", "group": "group_id", "public": "public_workspace_id"}[scope] + target = receipt["actor_user_id"] if scope == "personal" else destination[field] + escaped_id = document["id"].replace("'", "''") + escaped_target = target.replace("'", "''") + client = _get_search_client(**{ + key: destination[key] for key in ("group_id", "public_workspace_id") if key in destination + }) + results = client.search( + search_text="*", filter=f"document_id eq '{escaped_id}' and version eq {document['version']} and {field} eq '{escaped_target}'", + select=["id"], top=0, include_total_count=True, + connection_timeout=30, read_timeout=30, retry_total=0, + ) + count = results.get_count() + if type(count) is not int or count < 0: + raise ValueError("The exact indexed publication count is unavailable.") + return count + + +def inspect_publication_readiness(receipt, document, *, available_reader=None, index_count=None, check_index=True): + """Read native proof; never queue, approve, resume, index, or mutate a document.""" + processing = publication_processing_observation(receipt, document) + observation = {"processing": processing, "screening": "not_required", "index": "pending"} + if not publication_binding_matches(receipt, document): + return {**observation, "reason_code": "publication_revision_changed"} + if not _current_revision(document): + return {**observation, "reason_code": "publication_revision_changed"} + if (document.get(PUBLICATION_PROCESSING) or {}).get("reason_code") == "publication_content_changed": + return {**observation, "reason_code": "publication_content_changed"} + marker = document.get(SCREENING_FIELD) + expected_count = (document.get(PUBLICATION_PROCESSING) or {}).get("indexed_chunks") + if marker is not None: + if not isinstance(marker, dict): + return {**observation, "screening": "unavailable", "reason_code": "publication_screening_unavailable"} + state = marker.get("state") + if not isinstance(state, str) or state not in AVAILABLE_STATES | HELD_STATES: + return {**observation, "screening": "unavailable", "reason_code": "publication_screening_unavailable"} + if marker.get("sanitized") is True: + return {**observation, "screening": "changed", "reason_code": "publication_content_changed"} + if state in {"rejected", "deleting", "deleted"}: + return {**observation, "screening": "rejected", "reason_code": "publication_screening_rejected"} + if state not in AVAILABLE_STATES: + if state in {"scan_error", "incomplete"}: + return {**observation, "screening": "held", "reason_code": "publication_screening_unavailable"} + return {**observation, "screening": "held" if state in { + "pending_review", "remediating", + } else "pending"} + # The native release proof binds the scan, revision, canonical content and active blob. + if ( + marker.get("source_revision") != str(receipt["document_version"]) + or (marker.get("active_blob") or {}).get("content_hash") != receipt["content_sha256"] + ): + return {**observation, "screening": "changed", "reason_code": "publication_content_changed"} + expected_count = document.get("num_chunks") + observation.update(processing="complete", screening="available") + if observation["processing"] == "failed": + return {**observation, "reason_code": "publication_processing_failed"} + if observation["processing"] != "complete": + return observation + if type(expected_count) is not int or expected_count <= 0: + return {**observation, "index": "unavailable", "reason_code": "publication_no_indexed_content"} + destination = receipt["destination"] + scope_args = {key: destination[key] for key in ("group_id", "public_workspace_id") if key in destination} + try: + current = (available_reader or assert_document_available)( + document["id"], user_id=receipt["actor_user_id"], **scope_args, + ) + except DocumentHeldError: + return {**observation, "screening": "unavailable", "reason_code": "publication_screening_unavailable"} + if not _same_document(current, document) or not _current_revision(current) or current.get(SCREENING_FIELD) != marker: + return {**observation, "reason_code": "publication_revision_changed"} + if not check_index: + return observation + if (index_count or _index_count)(receipt, current) != expected_count: + return observation + refreshed = (available_reader or assert_document_available)( + document["id"], user_id=receipt["actor_user_id"], **scope_args, + ) + if ( + not _same_document(refreshed, current) or not _current_revision(refreshed) or refreshed.get(SCREENING_FIELD) != marker + or refreshed.get(PUBLICATION_PROCESSING) != current.get(PUBLICATION_PROCESSING) + or refreshed.get("num_chunks") != current.get("num_chunks") + ): + return {**observation, "reason_code": "publication_revision_changed"} + return {**observation, "index": "ready"} diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index 0bba29e95..3559dbfea 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -8,7 +8,7 @@ from io import BytesIO from flask import make_response from azure.core import MatchConditions -from azure.core.exceptions import ResourceExistsError +from azure.core.exceptions import AzureError, ResourceExistsError from content_screening.contracts import ( SCREENING_FIELD, DocumentHeldError, @@ -41,6 +41,9 @@ ) from config import * from functions_appinsights import log_event +from functions_artifact_publication_readiness import ( + PUBLICATION_BINDING, begin_publication_processing, finish_publication_processing, +) from functions_ai_connections import require_model_capability from functions_embedding_compatibility import active_embedding_profile, prepare_embedding_search_documents from functions_model_capabilities import is_vision_capable_model @@ -9379,6 +9382,21 @@ def _resolve_processing_complete_status(total_chunks_saved, file_ext, image_exte def process_document_upload_background(document_id, user_id, temp_file_path, original_filename, group_id=None, public_workspace_id=None, extraction_mode_override=None): """Keep screened intake private until its complete, revision-bound decision.""" document = get_document_metadata(document_id, user_id, group_id, public_workspace_id) + if document and document.get(PUBLICATION_BINDING): + try: + should_process = begin_publication_processing(document, temp_file_path) + except (AzureError, OSError, ValueError, RuntimeError) as exc: + log_event( + "[SIMPLE_CHAT] Publication native processing could not start", + extra={"document_id": document_id, "error_type": type(exc).__name__}, level=logging.WARNING, + ) + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + raise + if not should_process: + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + return if document_requires_screening(document, get_settings()): return process_screened_upload( document_id, user_id, temp_file_path, original_filename, @@ -9650,6 +9668,8 @@ def update_doc_callback(**kwargs): group_id=group_id, public_workspace_id=public_workspace_id ) + if final_document_metadata and final_document_metadata.get(PUBLICATION_BINDING): + finish_publication_processing(final_document_metadata, indexed_chunks=total_chunks_saved) sync_chat_upload_workspace_attachment_status(final_document_metadata) print(f"Document {document_id} ({original_filename}) processed successfully with {total_chunks_saved} chunks saved and {total_embedding_tokens} embedding tokens used.") @@ -9856,6 +9876,8 @@ def update_doc_callback(**kwargs): group_id=group_id, public_workspace_id=public_workspace_id ) + if failed_document_metadata and failed_document_metadata.get(PUBLICATION_BINDING): + finish_publication_processing(failed_document_metadata, failed=True) sync_chat_upload_workspace_attachment_status(failed_document_metadata) except Exception as update_e: print(f"Critical Error: Failed to update document status to error for {document_id}: {update_e}") diff --git a/application/single_app/functions_personal_workflows.py b/application/single_app/functions_personal_workflows.py index 0adaa4690..d3d9e1d53 100644 --- a/application/single_app/functions_personal_workflows.py +++ b/application/single_app/functions_personal_workflows.py @@ -41,7 +41,9 @@ from functions_workflow_result_store import delete_workflow_run_results from functions_workflow_bindings import authorize_workflow_reference from functions_workflow_definition_store import save_workflow_definition_record, update_workflow_runtime_record -from functions_workflow_definitions import normalize_workflow_definition, workflow_definition_for_editor +from functions_workflow_definitions import ( + normalize_publication_completion_policy, normalize_workflow_definition, workflow_definition_for_editor, +) from functions_workflow_runtime_store import workflow_runtime_store @@ -167,7 +169,7 @@ def normalize_workflow_publication(publication): if publication is None: return None if not isinstance(publication, dict) or set(publication) - { - 'artifact_format', 'workspace_scope', 'group_id', 'public_workspace_id', + 'artifact_format', 'workspace_scope', 'group_id', 'public_workspace_id', 'completion_policy', }: raise ValueError('Task publication must specify an artifact format and destination.') output_format = _normalize_text(publication.get('artifact_format'), 'Artifact format', required=True).lower() @@ -179,6 +181,8 @@ def normalize_workflow_publication(publication): if scope not in {'personal', 'group', 'public'}: raise ValueError('Publication destination must be personal, group, or public.') normalized = {'artifact_format': output_format, 'workspace_scope': scope} + if 'completion_policy' in publication: + normalized['completion_policy'] = normalize_publication_completion_policy(publication['completion_policy']) target_field = {'group': 'group_id', 'public': 'public_workspace_id'}.get(scope) for field in ('group_id', 'public_workspace_id'): value = _normalize_text(publication.get(field), 'Publication workspace id') diff --git a/application/single_app/functions_workflow_definitions.py b/application/single_app/functions_workflow_definitions.py index 53053b82d..e349a5943 100644 --- a/application/single_app/functions_workflow_definitions.py +++ b/application/single_app/functions_workflow_definitions.py @@ -14,6 +14,7 @@ WORKFLOW_BINDABLE_OUTPUTS = frozenset({"authoritative", "text", "records", "json", "documents"}) WORKFLOW_OUTPUT_KINDS = frozenset({"any", "text", "records", "json", "document_results"}) WORKFLOW_INPUT_PROCESSING_MODES = frozenset({"full", "saved_record_report"}) +WORKFLOW_PUBLICATION_COMPLETION_POLICIES = ("submitted", "approved", "indexed_ready") WORKFLOW_FLOW_TASK_FIELDS = frozenset({"inputs", "reference_ids", "output_contract", "approval", "input_processing"}) WORKFLOW_DEFINITION_FIELDS = ( "name", "description", "task_prompt", "tasks", "runner_type", "chat_capabilities_enabled", @@ -45,6 +46,23 @@ class WorkflowDefinitionConflict(WorkflowDefinitionError): """The editor is stale or cannot preserve the stored definition.""" +def normalize_publication_completion_policy(value): + if not isinstance(value, str) or value not in WORKFLOW_PUBLICATION_COMPLETION_POLICIES: + raise WorkflowDefinitionError("Publication completion must be submitted, approved, or indexed_ready.") + return value + + +def validate_workflow_publication_completion(workflow): + for task in workflow.get("tasks") or []: + if not isinstance(task, dict): + raise WorkflowDefinitionError("A workflow task must be an object.") + publication = task.get("publication") + if isinstance(publication, dict) and "completion_policy" in publication: + normalize_publication_completion_policy(publication["completion_policy"]) + if workflow.get("definition_version") != 3 or workflow.get("durable_execution") is not True: + raise WorkflowDefinitionError("Publication completion policies require a version-3 durable workflow.") + + def workflow_output_kind_matches(actual, expected): return expected == "any" or actual == expected or ( expected == "json" and actual in {"records", "document_results"} @@ -268,6 +286,10 @@ def normalize_workflow_definition(payload, existing, tasks, *, user_id, group_id if existing.get("active_run_id"): raise WorkflowDefinitionConflict("Wait for the active run to finish or cancel it before editing this workflow.") raw_tasks = payload.get("tasks", existing.get("tasks", [])) + validate_workflow_publication_completion({ + **payload, "tasks": raw_tasks, + "durable_execution": payload.get("durable_execution", existing.get("durable_execution", False)), + }) if len(raw_tasks) != len(tasks): raise WorkflowDefinitionError("Task data does not match the normalized task list.") if version != 3 and any("input_processing" in task for task in raw_tasks): diff --git a/application/single_app/functions_workflow_editor.py b/application/single_app/functions_workflow_editor.py index d3247c70b..d32cfa70e 100644 --- a/application/single_app/functions_workflow_editor.py +++ b/application/single_app/functions_workflow_editor.py @@ -2,7 +2,9 @@ """Non-secret editor choices and trusted loop-runner eligibility.""" from functions_ai_connections import supports_model_capability -from functions_workflow_definitions import WORKFLOW_DEFINITION_VERSION, WORKFLOW_INPUT_PROCESSING_MODES +from functions_workflow_definitions import ( + WORKFLOW_DEFINITION_VERSION, WORKFLOW_INPUT_PROCESSING_MODES, WORKFLOW_PUBLICATION_COMPLETION_POLICIES, +) from functions_workflow_flow import FLOW_LIMITS from functions_workflow_limits import ( WORKFLOW_LOOP_ITEMS_DEFAULT, @@ -60,6 +62,7 @@ def build_workflow_editor_options(*, scope_type, scope_id, can_manage, max_tasks "supported_query_modes": ["all_matches", "best_n"], "supported_binding_sources": ["node_output", "loop_item"], "supported_input_processing_modes": sorted(WORKFLOW_INPUT_PROCESSING_MODES), + "supported_publication_completion_policies": list(WORKFLOW_PUBLICATION_COMPLETION_POLICIES), "flow_limits": { **FLOW_LIMITS, "max_loop_items": validate_workflow_max_loop_items(max_loop_items), diff --git a/application/single_app/functions_workflow_flow.py b/application/single_app/functions_workflow_flow.py index 774807c6c..4c142c7c9 100644 --- a/application/single_app/functions_workflow_flow.py +++ b/application/single_app/functions_workflow_flow.py @@ -9,7 +9,7 @@ from functions_workflow_definitions import ( WORKFLOW_BINDABLE_OUTPUTS, WORKFLOW_OUTPUT_KINDS, WorkflowDefinitionError, _boolean, _name, _object, normalize_workflow_input_processing, normalize_workflow_output_contract, - workflow_output_kind_matches, + validate_workflow_publication_completion, workflow_output_kind_matches, ) from functions_workflow_loop_schema import WORKFLOW_DOCUMENT_ITEM_SCHEMA, normalize_workflow_iterable @@ -192,6 +192,7 @@ def compile_workflow_flow(workflow): or workflow.get("definition_version") != 3 or workflow.get("durable_execution") is not True ): raise WorkflowDefinitionError("Structured workflows require definition version 3 and durable execution.") + validate_workflow_publication_completion(workflow) if isinstance(workflow.get("document_action"), dict) and workflow["document_action"].get("target_mode") == "current_item": raise WorkflowDefinitionError("Current-item Analyze must be declared on a task inside a document loop.") tasks = workflow.get("tasks") diff --git a/application/single_app/functions_workflow_node_results.py b/application/single_app/functions_workflow_node_results.py index aa07773fc..ec8cd93b7 100644 --- a/application/single_app/functions_workflow_node_results.py +++ b/application/single_app/functions_workflow_node_results.py @@ -106,6 +106,15 @@ def enter(producer, ref, current): current.get("contract_version") != "workflow-result-v2" or current.get("identity") != expected ): raise AnalysisResultUnavailable("analysis_lineage_invalid") + if current.get("publication"): + # Result readers own source lineage; publication owns the additional destination boundary. + from functions_artifact_publication import authorize_publication_status_read + from functions_workflow_runtime_store import workflow_runtime_store + + actor = workflow_runtime_store(workflow, run_id).read()["actor_user_id"] + authorize_publication_status_read( + reader_user_id or workflow["user_id"], current["publication"], actor_user_id=actor, + ) if producer["iteration_path"]: from functions_workflow_iterations import authorize_iteration_path diff --git a/application/single_app/functions_workflow_readiness.py b/application/single_app/functions_workflow_readiness.py index 801d7d767..27b4386f3 100644 --- a/application/single_app/functions_workflow_readiness.py +++ b/application/single_app/functions_workflow_readiness.py @@ -12,6 +12,8 @@ class WorkflowOutputUnavailable(ValueError): def pending_workflow_output_references(result): + if "_publication_reference" in result: + return [validate_publication_reference(result["_publication_reference"])] analysis = result.get("analysis_result") or result.get("comparison_result") or {} outputs = ( list(result.get("generated_tabular_outputs") or analysis.get("generated_tabular_outputs") or []) @@ -41,10 +43,72 @@ def workflow_outputs_ready(workflow, references, *, get_status=None): if not references: return False read = get_status or _child_status - return all( - read(workflow, reference).get("status") in {"completed", "failed", "cancelled", "canceled"} - for reference in references + for reference in references: + if reference.get("kind") == "artifact_publication": + validate_publication_reference(reference) + # The existing worker polls under its lease and current initiating actor. + # Requeueing an observer is not proof of publication completion. + continue + if read(workflow, reference).get("status") not in {"completed", "failed", "cancelled", "canceled"}: + return False + return True + + +def validate_publication_reference(reference): + if ( + not isinstance(reference, dict) or set(reference) != { + "kind", "version", "execution_id", "attempt", "request_id", "receipt_id", + } + or reference.get("kind") != "artifact_publication" or type(reference.get("version")) is not int + or reference["version"] != 1 or type(reference.get("attempt")) is not int or reference["attempt"] < 1 + or any(not isinstance(reference.get(key), str) or not reference[key] or len(reference[key]) > 512 + for key in ("execution_id", "request_id", "receipt_id")) + ): + raise WorkflowOutputUnavailable("The saved publication continuation is unsupported.") + return deepcopy(reference) + + +def reconcile_workflow_publication_output(workflow, result, *, execution, actor_user_id): + # Publication imports the workflow stores; resolve it only for this typed continuation. + from functions_artifact_publication import read_workflow_artifact_publication + + reference = validate_publication_reference(result.get("_publication_reference")) + request = result.get("_publication_request") + if not isinstance(request, dict) or not isinstance(request.get("source_receipt"), dict): + raise WorkflowOutputUnavailable("The saved publication request is unavailable.") + task = next((task for task in workflow["tasks"] if task["id"] == execution.node.get("task_id")), None) + producer = request["source_receipt"].get("producer") or {} + if not isinstance(producer, dict): + raise WorkflowOutputUnavailable("The saved publication producer is unavailable.") + if ( + not task or workflow.get("definition_version") != 3 or workflow.get("durable_execution") is not True + or not isinstance(request, dict) or request.get("publication") != task.get("publication") + or reference["execution_id"] != execution.execution_id() + or reference["attempt"] != execution.unit(f"task:{task['id']}").get("attempt") + or request.get("receipt_id") != reference["receipt_id"] + or request.get("request_id") != reference["request_id"] + or producer.get("workflow_id") != workflow["id"] or producer.get("run_id") != execution.run_id + or request["request_id"] != ( + f"workflow-publication:v3:{execution.execution_id()}:{producer.get('execution_id')}:{producer.get('attempt')}" + ) + ): + raise WorkflowOutputUnavailable("The saved publication does not match this exact workflow execution.") + execution.check() + if result.get("execution_status") == "succeeded" and (result.get("publication") or {}).get("policy_satisfied") is True: + read_workflow_artifact_publication( + actor_user_id, request, authorization_only=True, execution_check=execution.check, + ) + return deepcopy(result) + observed = read_workflow_artifact_publication( + actor_user_id, request, reconcile=True, execution_check=execution.check, + ) + refreshed = deepcopy(result) + refreshed.update( + reply=observed["message"], publication=observed["publication"], + authoritative_result={"kind": "json", "value": {"publication": observed["publication"]}}, + execution_status="succeeded" if observed["publication"]["policy_satisfied"] else "pending", ) + return refreshed def reconcile_workflow_pending_output(workflow, result, *, conversation_id, actor_user_id, diff --git a/application/single_app/functions_workflow_results.py b/application/single_app/functions_workflow_results.py index 8e05abacf..5c364f8d7 100644 --- a/application/single_app/functions_workflow_results.py +++ b/application/single_app/functions_workflow_results.py @@ -11,6 +11,7 @@ analysis_source_snapshot, authorize_analysis_sources, ) +from functions_artifact_publication_readiness import public_publication_status from functions_workflow_result_store import ( DEFAULT_MAX_RESULT_SIZE_MB, _quota_bytes, @@ -346,6 +347,8 @@ def _build_task_result(result, identity, contract_version): ) if result.get("analysis_consumption"): envelope["analysis_consumption"] = _json_copy(result["analysis_consumption"]) + if isinstance(result.get("publication"), Mapping) and result["publication"].get("version") == 1: + envelope["publication"] = public_publication_status(result["publication"]) if analysis.get("native_result_references"): envelope["native_result_references"] = _json_copy(analysis["native_result_references"]) # Reject unsupported SDK objects/NaN before any output is marked durable. @@ -942,6 +945,8 @@ def workflow_result_summary(envelope, reference): } if envelope.get("contract_version") == "workflow-result-v2": summary["producer"] = _json_copy(envelope["identity"]) + if envelope.get("publication"): + summary["publication"] = public_publication_status(envelope["publication"]) if envelope.get("iteration_inputs"): summary["iteration_inputs"] = _json_copy(envelope["iteration_inputs"]) if envelope.get("consumed_inputs_index"): diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index 1ccaae6bf..13a1409f2 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -220,6 +220,7 @@ WorkflowOutputUnavailable, pending_workflow_output_references, reconcile_workflow_pending_output, + reconcile_workflow_publication_output, ) from functions_workflow_runtime_store import WorkflowRuntimeConflict from functions_workflow_results import ( @@ -10418,6 +10419,9 @@ def raise_if_cancelled(): try: if task.get('publication') is not None: task_stage = 'publication' + publication_completion = 'completion_policy' in task['publication'] + if publication_completion and (not structured_definition or durable is None): + raise WorkflowResultNotReadyError('Publication completion requires a version-3 durable workflow.') publication_inputs = flow_runner.resolve(task['inputs'], metadata_only=True) if flow_runner else None task_result, consumed_inputs = workflow_unit( task_unit_key, @@ -10430,6 +10434,7 @@ def raise_if_cancelled(): if publication_inputs is not None else {'task': task, 'producer_task_id': previous_task_id, 'result_ref': previous_result_ref}), approval=task.get('approval'), + replay_safe=publication_completion, ) if durable is not None: attempt_count = durable.unit(task_unit_key)['attempt'] @@ -10612,6 +10617,17 @@ def dispatch_task(): break except Exception as exc: assert_workflow_execution_owned() + if (task.get('publication') or {}).get('completion_policy') and structured_definition and durable is not None: + log_event( + '[WORKFLOW_RUNNER] Publication boundary unavailable', + extra={'run_id': run_id, 'task_id': task_id, 'error_type': type(exc).__name__}, + level=logging.WARNING, + ) + durable.wait_for_publication( + task_unit_key, + reason='Publication could not be confirmed. Restore current source and destination access, then recheck the same request.', + retryable=True, + ) task_result = None blocked_audit = ((attempt_workflow or {}).get('context_budget') or {}).get('blocked_request') safe_error = WorkflowContextBudgetError(blocked_audit) if blocked_audit else exc @@ -10677,6 +10693,49 @@ def dispatch_task(): task_result, workflow=workflow, run_id=run_id, task=task, attempt_count=attempt_count, ) + if (task.get('publication') or {}).get('completion_policy'): + try: + task_result = reconcile_workflow_publication_output( + workflow, task_result, execution=durable, actor_user_id=actor_id, + ) + except (AzureError, OSError, RuntimeError, ValueError, PermissionError, LookupError) as exc: + log_event( + '[WORKFLOW_RUNNER] Publication reconciliation unavailable', + extra={'run_id': run_id, 'task_id': task_id, 'error_type': type(exc).__name__}, + level=logging.WARNING, + ) + durable.wait_for_publication( + task_unit_key, + reason='Publication status is unavailable. Restore access or the existing destination, then recheck this receipt.', + retryable=not isinstance(exc, WorkflowOutputUnavailable), + ) + durable.replace_unit_result(task_unit_key, (task_result, consumed_inputs)) + envelope = build_workflow_task_result( + task_result, workflow=workflow, run_id=run_id, task=task, attempt_count=attempt_count, + ) + if not task_result['publication']['policy_satisfied']: + envelope.update(context_budget=context_budget, consumed_inputs=consumed_inputs) + pending_manifest, pending_ref = persist_workflow_task_result( + envelope, workflow=workflow, run_id=run_id, task_id=task_id, settings=settings, + ) + pending_summary = workflow_result_summary(pending_manifest, pending_ref) + waiting = task_result['publication']['state'].startswith('waiting_') + pending_state = 'waiting_output' if waiting else 'paused' + _save_workflow_task_run_item( + workflow, run_id, task, pending_state, attempt_count=attempt_count, + created_at=created_at, runner_audit=runner_audit, result_summary=pending_summary, + consumed_inputs=consumed_inputs, + ) + durable.record_execution( + state=pending_state, attempt=attempt_count, + workflow_result=pending_summary, consumed_inputs=consumed_inputs, + ) + durable._attempt(attempt_count, state=pending_state, workflow_result=pending_summary) + durable.wait_for_publication( + task_unit_key, reference=task_result['_publication_reference'], + publication=task_result['publication'], reason=task_result['reply'], + retryable=task_result['publication']['retryable'], + ) if structured_definition and _get_document_action_config(attempt_workflow).get('type') == DOCUMENT_ACTION_TYPE_ANALYZE: if analysis_checkpoints is None: analysis_checkpoints = _prepare_workflow_analysis_checkpoints(workflow, run_id, task_id, actor_id, settings) @@ -11012,6 +11071,18 @@ def _execute_workflow_analysis_publication( f"workflow-publication:v3:{execution.execution_id()}:" f"{producer['execution_id']}:{producer['attempt']}" ) + output_name = manifest['authoritative_output'] + native_receipt = { + 'producer': manifest['identity'], 'output_name': output_name, + 'result_ref': dict(reference), 'output_ref': manifest['outputs'][output_name]['result_ref'], + 'analysis_result': True, + } + completion_options = {} + if 'completion_policy' in publication: + execution = current_workflow_execution() + if workflow.get('definition_version') != 3 or execution is None: + raise WorkflowResultNotReadyError('Publication completion requires the current structured execution.') + completion_options = {'source_receipt': native_receipt, 'execution_check': execution.check} result = (publish or publish_workflow_analysis_artifact)( actor, publication=publication, artifact_reference={ @@ -11019,18 +11090,19 @@ def _execute_workflow_analysis_publication( 'artifact_message_id': artifact.get('artifact_message_id'), 'producer': producer, }, request_id=publication_request_id, + **completion_options, ) state = (result.get('publication') or {}).get('state') - if state == 'pending_approval': + if 'completion_policy' in publication: + result['_publication_reference'] = { + 'kind': 'artifact_publication', 'version': 1, 'execution_id': execution.execution_id(), + 'attempt': execution.selectors()['attempt'], 'request_id': publication_request_id, + 'receipt_id': result['publication']['id'], + } + elif state == 'pending_approval': result['execution_status'] = 'pending' elif state in {'uncertain', 'approval_failed'}: result['execution_status'] = 'blocked' - output_name = manifest['authoritative_output'] - native_receipt = { - 'producer': manifest['identity'], 'output_name': output_name, - 'result_ref': dict(reference), 'output_ref': manifest['outputs'][output_name]['result_ref'], - 'analysis_result': True, - } return result, ( [*explicit_inputs, native_receipt] if workflow.get('definition_version') == 3 and explicit_inputs[0]['producer'] != native_receipt['producer'] else [native_receipt] diff --git a/application/single_app/functions_workflow_runtime.py b/application/single_app/functions_workflow_runtime.py index 06995bf1d..bed0e2274 100644 --- a/application/single_app/functions_workflow_runtime.py +++ b/application/single_app/functions_workflow_runtime.py @@ -15,7 +15,9 @@ from azure.cosmos.exceptions import CosmosHttpResponseError, CosmosResourceExistsError, CosmosResourceNotFoundError from functions_appinsights import log_event -from functions_workflow_definitions import WORKFLOW_DEFINITION_FIELDS, workflow_definition_revision +from functions_workflow_definitions import ( + WORKFLOW_DEFINITION_FIELDS, validate_workflow_publication_completion, workflow_definition_revision, +) from functions_workflow_execution import DurableWorkflowExecution, WorkflowSuspended, workflow_execution_scope from functions_workflow_structured_execution import StructuredWorkflowExecution from functions_workflow_flow import compile_workflow_flow @@ -70,6 +72,7 @@ def _authorize_execution(workflow, actor_user_id, settings): from functions_group_workflows import GROUP_WORKFLOW_MEMBER_ROLES from functions_settings import is_group_workflows_enabled_for_group + validate_workflow_publication_completion(workflow) if workflow.get("deleting"): raise WorkflowRuntimeConflict("workflow_deleting", "This workflow is being deleted.") if workflow.get("group_id"): @@ -289,7 +292,23 @@ def workflow_runtime_status(workflow, run_id, *, reader_user_id): if run.get("workflow_id") != workflow["id"] or run.get("durable_execution") is not True: raise LookupError("Durable workflow run not found.") authorize_workflow_run_read(workflow, run_id, reader_user_id=reader_user_id) - return workflow_runtime_projection(workflow_runtime_store(workflow, run_id).read()) + store = workflow_runtime_store(workflow, run_id) + control = store.read() + gate = control.get("gate") or {} + if gate.get("publication"): + # Polling UI has the same source and destination boundary as exact result inspection. + from functions_artifact_publication import authorize_publication_status_read + from functions_workflow_execution_history import authorize_execution_payload + + snapshot = store.run_definition() + row = store.journal_read("execution", gate.get("execution_id")) + if row is None: + raise LookupError("Publication execution not found.") + authorize_execution_payload(snapshot, run_id, row["payload"], reader_user_id=reader_user_id) + authorize_publication_status_read( + reader_user_id, gate["publication"], actor_user_id=control["actor_user_id"], + ) + return workflow_runtime_projection(control) def decide_workflow_runtime(workflow, run_id, data, *, actor_user_id, resume=False): diff --git a/application/single_app/functions_workflow_runtime_store.py b/application/single_app/functions_workflow_runtime_store.py index 988c723f8..3a1de7d52 100644 --- a/application/single_app/functions_workflow_runtime_store.py +++ b/application/single_app/functions_workflow_runtime_store.py @@ -21,6 +21,7 @@ from azure.cosmos import exceptions as cosmos_exceptions from functions_workflow_journal import WorkflowJournalMixin from functions_workflow_identity import workflow_execution_id +from functions_artifact_publication_readiness import public_publication_status CONTROL_ID = "workflow-runtime:v1" @@ -77,6 +78,7 @@ "provider", "retryable", "metadata", + "publication", }) GATE_KIND_BY_STATE = { "waiting_approval": "approval", @@ -293,6 +295,8 @@ def _validate_gate(gate, state): if kind == "output" and choices: raise RuntimeConflict("invalid_gate", "Output gates cannot declare human approval choices.") normalized = _bounded_json_copy({**gate, "id": gate_id, "kind": kind, "choices": choices}, max_bytes=MAX_GATE_BYTES) + if "publication" in normalized: + normalized["publication"] = public_publication_status(normalized["publication"]) return normalized @@ -424,6 +428,8 @@ def public_projection(control): if key == "references": references = gate[key] if isinstance(gate[key], list) else [] safe_gate[key] = {"count": len(references)} + elif key == "publication": + safe_gate[key] = public_publication_status(gate[key]) elif key.endswith("_ref"): safe_gate[key] = _safe_ref(gate[key]) else: diff --git a/application/single_app/functions_workflow_structured_execution.py b/application/single_app/functions_workflow_structured_execution.py index 7d8f8e0b3..ca353af9c 100644 --- a/application/single_app/functions_workflow_structured_execution.py +++ b/application/single_app/functions_workflow_structured_execution.py @@ -263,7 +263,7 @@ def _attempt(self, attempt, **fields): row = self.store.journal_read("execution", self.execution_id()) payload = {**(row["payload"] if row else {}), "attempt": attempt, **fields} previous = self.store.journal_read("attempt", [self.execution_id(), attempt]) - if previous and previous["payload"].get("state") not in {"running", "waiting_output", "pending", "waiting_approval", "waiting_recovery"}: + if previous and previous["payload"].get("state") not in {"running", "waiting_output", "pending", "waiting_approval", "waiting_recovery", "paused"}: if previous["payload"].get("workflow_result") != payload.get("workflow_result"): raise WorkflowRuntimeConflict("immutable_attempt_conflict") if fields.get("state") not in {previous["payload"]["state"], "waiting_recovery"}: @@ -298,6 +298,26 @@ def replace_unit_result(self, key, value): }) self.store.journal_commit(self.lease.token, "unit", self._key(key), {**unit, "result_ref": reference}) + def wait_for_publication(self, key, *, reference=None, publication=None, reason, retryable=False): + control = self.check() + unit = self.unit(key) + waiting = publication is not None and publication["state"].startswith("waiting_") + state = "waiting_output" if waiting else "paused" + self.record_execution(state=state, reason_code=(publication or {}).get("reason_code") or "publication_unavailable") + gate = { + "id": execution_fingerprint([self.execution_id(), unit.get("attempt"), key, state, publication, control["version"]]), + "kind": "output" if waiting else "pause", "unit_id": key, + "input_digest": unit.get("input_digest") or "", **self.selectors(), + "definition_revision": self.workflow.get("definition_revision"), + "reason": reason, "choices": [] if waiting else ["resume", "cancel"] if retryable else ["cancel"], + } + if reference is not None: + gate["references"] = [deepcopy(reference)] + if publication is not None: + gate["publication"] = deepcopy(publication) + self.store.wait(self.lease.token, state=state, gate=gate) + raise WorkflowSuspended(state) + def wait_for_output(self, key, references): unit = self.unit(key) self.record_execution(state="waiting_output", attempt=int(unit.get("attempt") or 1)) diff --git a/application/single_app/route_backend_group_documents.py b/application/single_app/route_backend_group_documents.py index b4e207da6..53d81f86b 100644 --- a/application/single_app/route_backend_group_documents.py +++ b/application/single_app/route_backend_group_documents.py @@ -12,6 +12,7 @@ from functions_documents import * from content_screening.service import prepare_document_upload from functions_appinsights import log_event +from functions_artifact_publication import decide_artifact_publication from functions_file_sync import ( FILE_SYNC_SCOPE_GROUP, apply_synced_document_delete_action, @@ -1580,6 +1581,12 @@ def api_approve_group_generated_artifact(document_id): if not document_item: return jsonify({'error': 'Document not found or access denied'}), 404 + if document_item.get('generated_artifact_publication_binding'): + result = decide_artifact_publication(user_id, document_item, 'approved') + _cleanup_group_generated_artifact_notifications(document_id, active_group_id) + invalidate_group_search_cache(active_group_id) + return jsonify(result), 200 + promotion_status = str(document_item.get('generated_artifact_promotion_status') or '').strip().lower() if promotion_status != 'pending_approval': return jsonify({'error': 'Document is not awaiting generated artifact approval'}), 400 @@ -1695,6 +1702,12 @@ def api_deny_group_generated_artifact(document_id): if not document_item: return jsonify({'error': 'Document not found or access denied'}), 404 + if document_item.get('generated_artifact_publication_binding'): + result = decide_artifact_publication(user_id, document_item, 'rejected') + _cleanup_group_generated_artifact_notifications(document_id, active_group_id) + invalidate_group_search_cache(active_group_id) + return jsonify(result), 200 + promotion_status = str(document_item.get('generated_artifact_promotion_status') or '').strip().lower() if promotion_status != 'pending_approval': return jsonify({'error': 'Document is not awaiting generated artifact approval'}), 400 @@ -1774,6 +1787,12 @@ def api_cancel_group_generated_artifact(document_id): if not document_item: return jsonify({'error': 'Document not found or access denied'}), 404 + if document_item.get('generated_artifact_publication_binding'): + result = decide_artifact_publication(user_id, document_item, 'cancelled') + _cleanup_group_generated_artifact_notifications(document_id, active_group_id) + invalidate_group_search_cache(active_group_id) + return jsonify(result), 200 + promotion_status = str(document_item.get('generated_artifact_promotion_status') or '').strip().lower() if promotion_status != 'pending_approval': return jsonify({'error': 'Document is not awaiting generated artifact approval'}), 400 diff --git a/application/single_app/route_backend_public_documents.py b/application/single_app/route_backend_public_documents.py index 1c3362804..1fbb1e2b2 100644 --- a/application/single_app/route_backend_public_documents.py +++ b/application/single_app/route_backend_public_documents.py @@ -13,6 +13,7 @@ from functions_documents import * from content_screening.service import prepare_document_upload from functions_appinsights import log_event +from functions_artifact_publication import decide_artifact_publication from functions_document_access_index import ( DOCUMENT_ACCESS_SCOPE_PUBLIC, build_document_access_scope_key, @@ -809,6 +810,12 @@ def api_approve_public_generated_artifact(doc_id): if not document_item: return jsonify({'error': 'Document not found or access denied'}), 404 + if document_item.get('generated_artifact_publication_binding'): + result = decide_artifact_publication(user_id, document_item, 'approved') + _cleanup_public_generated_artifact_notifications(doc_id, active_ws) + invalidate_public_workspace_search_cache(active_ws) + return jsonify(result), 200 + promotion_status = str(document_item.get('generated_artifact_promotion_status') or '').strip().lower() if promotion_status != 'pending_approval': return jsonify({'error': 'Document is not awaiting generated artifact approval'}), 400 @@ -915,6 +922,12 @@ def api_deny_public_generated_artifact(doc_id): if not document_item: return jsonify({'error': 'Document not found or access denied'}), 404 + if document_item.get('generated_artifact_publication_binding'): + result = decide_artifact_publication(user_id, document_item, 'rejected') + _cleanup_public_generated_artifact_notifications(doc_id, active_ws) + invalidate_public_workspace_search_cache(active_ws) + return jsonify(result), 200 + promotion_status = str(document_item.get('generated_artifact_promotion_status') or '').strip().lower() if promotion_status != 'pending_approval': return jsonify({'error': 'Document is not awaiting generated artifact approval'}), 400 @@ -986,6 +999,12 @@ def api_cancel_public_generated_artifact(doc_id): if not document_item: return jsonify({'error': 'Document not found or access denied'}), 404 + if document_item.get('generated_artifact_publication_binding'): + result = decide_artifact_publication(user_id, document_item, 'cancelled') + _cleanup_public_generated_artifact_notifications(doc_id, active_ws) + invalidate_public_workspace_search_cache(active_ws) + return jsonify(result), 200 + promotion_status = str(document_item.get('generated_artifact_promotion_status') or '').strip().lower() if promotion_status != 'pending_approval': return jsonify({'error': 'Document is not awaiting generated artifact approval'}), 400 diff --git a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx index cf07862d9..b8ee05073 100644 --- a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx +++ b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx @@ -28,6 +28,7 @@ import { findWorkflowAgent, newWorkflowDefinition, normalizeWorkflowDefinition, + isWorkflowPublicationCompletionPolicy, preservedWorkflowFieldLabels, safeWorkflowAlias, sameWorkflowDefinition, @@ -44,6 +45,7 @@ import { WORKFLOW_OUTPUT_KINDS, WORKFLOW_SCHEMA_LIMIT, WORKFLOW_TASK_INSTRUCTIONS_LIMIT, + WORKFLOW_PUBLICATION_COMPLETION_LABELS, type WorkflowDefinition, type WorkflowDocumentAction, type WorkflowEditorOptions, @@ -1106,7 +1108,8 @@ function TaskCard({
Runner, inputs, references and outputs
- {structuredNode ? : null} + {structuredNode ? : null} {!task.publication ? void }) { +function TaskPublicationFields({ task, options, durableExecution, onChange }: { + task: WorkflowTask; + options: WorkflowEditorOptions; + durableExecution: boolean; + onChange: (task: WorkflowTask) => void; +}) { const publication = task.publication; + const supportedPolicies = options.supported_publication_completion_policies ?? []; const update = (value: WorkflowPublication) => onChange({ ...task, publication: value }); + if (publication && Object.hasOwn(publication, 'completion_policy') && + !isWorkflowPublicationCompletionPolicy(publication.completion_policy)) { + return

The saved publication completion policy is unsupported. Its original configuration is preserved and read-only.

; + } return (
{ if (checked) onChange({ - ...task, publication: { artifact_format: 'md', workspace_scope: 'personal' }, + ...task, publication: { + artifact_format: 'md', workspace_scope: 'personal', + ...(durableExecution && supportedPolicies.includes('submitted') ? { completion_policy: 'submitted' } : {}), + }, runner: { type: 'inherit' }, document_action: { type: 'none' }, output_contract: { kind: 'json', require_complete_coverage: false, allow_partial: false }, }); @@ -1220,10 +1236,12 @@ function TaskPublicationFields({ task, onChange }: { task: WorkflowTask; onChang supportedPolicies.includes(policy))} + onChange={(event) => { + const policy = event.target.value; + const next = { ...publication }; + if (policy === '') delete next.completion_policy; + else if (isWorkflowPublicationCompletionPolicy(policy) && supportedPolicies.includes(policy)) { + next.completion_policy = policy; + } else return; + update(next); + }}> + + {Object.entries(WORKFLOW_PUBLICATION_COMPLETION_LABELS).map(([policy, label]) => ( + + ))} + + + {publication.completion_policy === 'submitted' + ? 'Confirm submission and the required handoff, without waiting for approval or indexing.' + : publication.completion_policy === 'approved' + ? publication.workspace_scope === 'personal' + ? 'Personal workspace approval is not required. Complete after confirmed submission.' + : 'Wait for the existing destination workspace approval, not workflow task approval.' + : publication.completion_policy === 'indexed_ready' + ? 'Wait for approval where required, completed processing, screening clearance, and search visibility.' + : 'Preserve the existing behavior until you explicitly choose a supported completion policy.'} + +

Bind exactly one earlier native Analyze output below. The destination is explicit, not your active workspace. Group/public approval and processing remain separate; queued does not mean indexed and ready. diff --git a/application/v2_ui/src/components/workflows/WorkflowExecutionHistory.tsx b/application/v2_ui/src/components/workflows/WorkflowExecutionHistory.tsx index a5c3a1f64..367bcb4e8 100644 --- a/application/v2_ui/src/components/workflows/WorkflowExecutionHistory.tsx +++ b/application/v2_ui/src/components/workflows/WorkflowExecutionHistory.tsx @@ -32,6 +32,7 @@ import { import { GlassButton, GlassPanel } from '../ui/primitives'; import { Pill, RowAction } from '../workspace/primitives'; import { WorkflowLoopSelectionDetails } from './WorkflowLoopSelectionDetails'; +import { WorkflowPublicationDetails } from './WorkflowPublicationDetails'; interface PagedState { items: T[]; @@ -174,6 +175,7 @@ function historyErrorMessage(cause: unknown, fallback: string): string { function usePagedResource( loadPage: (cursor: string | null, signal: AbortSignal) => Promise>, fallbackError: string, + onAccessLost?: (status: number) => void, ) { const [state, setState] = useState>({ items: [], @@ -228,8 +230,11 @@ function usePagedResource( loading: false, error: historyErrorMessage(cause, fallbackError), })); + if (cause instanceof ApiError && (cause.status === 403 || cause.status === 404)) { + onAccessLost?.(cause.status); + } }); - }, [fallbackError, loadPage]); + }, [fallbackError, loadPage, onAccessLost]); useEffect(() => { load(null, []); @@ -392,12 +397,14 @@ function V3ResultExcerpt({ runId, executionId, attempt, + onAccessLost, }: { scope: WorkflowScope; workflowId: string; runId: string; executionId: string; attempt: number; + onAccessLost?: (status: number) => void; }) { const [page, setPage] = useState(null); const [loading, setLoading] = useState(false); @@ -438,6 +445,9 @@ function V3ResultExcerpt({ } setError(historyErrorMessage(cause, 'Could not load the execution result excerpt.')); setPage(null); + if (cause instanceof ApiError && (cause.status === 403 || cause.status === 404)) { + onAccessLost?.(cause.status); + } }) .finally(() => { if (controller.signal.aborted || token !== tokenRef.current) { @@ -487,16 +497,18 @@ function AttemptHistory({ workflowId, runId, executionId, + onAccessLost, }: { scope: WorkflowScope; workflowId: string; runId: string; executionId: string; + onAccessLost?: (status: number) => void; }) { const loadPage = useCallback((cursor: string | null, signal: AbortSignal) => fetchWorkflowExecutionAttemptsPage(scope, workflowId, runId, executionId, cursor, 50, signal), [executionId, runId, scope, workflowId]); - const page = usePagedResource(loadPage, 'Could not load execution attempts.'); + const page = usePagedResource(loadPage, 'Could not load execution attempts.', onAccessLost); return (

@@ -534,10 +546,13 @@ function AttemptHistory({ {resultSummary ? {resultSummary} : null} {attempt.iteration_path?.length ? {formatIterationPath(attempt.iteration_path)} : null} + {attempt.workflow_result?.result_ref && collectionOutputs.length ? : null} + runId={runId} executionId={attempt.execution_id} attempt={attempt.attempt} outputs={collectionOutputs} + onAccessLost={onAccessLost} /> : null} {attempt.workflow_result?.result_ref ? :

No result was committed for this attempt.

} ); @@ -554,13 +570,14 @@ function AttemptHistory({ ); } -function RecordPages({ scope, workflowId, runId, executionId, attempt, output }: { +function RecordPages({ scope, workflowId, runId, executionId, attempt, output, onAccessLost }: { scope: WorkflowScope; workflowId: string; runId: string; executionId: string; attempt: number; output: string; + onAccessLost?: (status: number) => void; }) { const loadPage = useCallback((cursor: string | null, signal: AbortSignal) => fetchWorkflowExecutionRecordsPage(scope, workflowId, runId, executionId, attempt, output, cursor, 100, signal), [attempt, executionId, output, runId, scope, workflowId]); - const page = usePagedResource(loadPage, 'Could not read the complete saved records.'); + const page = usePagedResource(loadPage, 'Could not read the complete saved records.', onAccessLost); const summary = validationSummary(page.metadata?.validation); const coverage = page.metadata?.coverage; return ( @@ -591,8 +608,9 @@ function RecordPages({ scope, workflowId, runId, executionId, attempt, output }: ); } -function CompleteRecords({ scope, workflowId, runId, executionId, attempt, outputs }: { +function CompleteRecords({ scope, workflowId, runId, executionId, attempt, outputs, onAccessLost }: { scope: WorkflowScope; workflowId: string; runId: string; executionId: string; attempt: number; outputs: string[]; + onAccessLost?: (status: number) => void; }) { const [output, setOutput] = useState(outputs[0]); const [open, setOpen] = useState(false); @@ -607,21 +625,22 @@ function CompleteRecords({ scope, workflowId, runId, executionId, attempt, outpu setOpen(!open)}>{open ? 'Close complete records' : 'Load complete records'} {open ? : null} + runId={runId} executionId={executionId} attempt={attempt} output={output} onAccessLost={onAccessLost} /> : null} setProvenanceOpen(!provenanceOpen)}>{provenanceOpen ? 'Close contributors' : 'Inspect contributors'} {provenanceOpen ? : null} + workflowId={workflowId} runId={runId} executionId={executionId} attempt={attempt} onAccessLost={onAccessLost} /> : null} ); } -function ContributorPages({ scope, workflowId, runId, executionId, attempt }: { +function ContributorPages({ scope, workflowId, runId, executionId, attempt, onAccessLost }: { scope: WorkflowScope; workflowId: string; runId: string; executionId: string; attempt: number; + onAccessLost?: (status: number) => void; }) { const loadPage = useCallback((cursor: string | null, signal: AbortSignal) => fetchWorkflowExecutionProvenancePage(scope, workflowId, runId, executionId, attempt, cursor, 50, signal), [attempt, executionId, runId, scope, workflowId]); - const page = usePagedResource(loadPage, 'Could not read the saved contributor receipts.'); + const page = usePagedResource(loadPage, 'Could not read the saved contributor receipts.', onAccessLost); return (
void; }) { const [selected, setSelected] = useState(null); return ( <> {item.execution_ids?.map((id) => setSelected(selected === id ? null : id)}>Inspect execution {id})} - {selected ? : null} + {selected ? : null} ); } -function LoopItems({ scope, workflowId, runId, executionId }: { +function LoopItems({ scope, workflowId, runId, executionId, onAccessLost }: { scope: WorkflowScope; workflowId: string; runId: string; executionId: string; + onAccessLost?: (status: number) => void; }) { const loadPage = useCallback((cursor: string | null, signal: AbortSignal) => fetchWorkflowLoopItemsPage(scope, workflowId, runId, executionId, cursor, 50, signal), [executionId, runId, scope, workflowId]); - const page = usePagedResource(loadPage, 'Could not read the frozen loop items.'); + const page = usePagedResource(loadPage, 'Could not read the frozen loop items.', onAccessLost); return (
{item.item_id} {formatIterationPath(item.iteration_path)} {item.record_count !== undefined ? {item.record_count} : null} - + )} : null}
@@ -693,15 +715,17 @@ function DecisionHistory({ scope, workflowId, runId, + onAccessLost, }: { scope: WorkflowScope; workflowId: string; runId: string; + onAccessLost?: (status: number) => void; }) { const loadPage = useCallback((cursor: string | null, signal: AbortSignal) => fetchWorkflowRuntimeDecisionsPage(scope, workflowId, runId, cursor, 50, signal), [runId, scope, workflowId]); - const page = usePagedResource(loadPage, 'Could not load runtime decisions.'); + const page = usePagedResource(loadPage, 'Could not load runtime decisions.', onAccessLost); return (
@@ -760,16 +784,18 @@ export function WorkflowExecutionHistory({ scope, workflowId, runId, + onAccessLost, }: { scope: WorkflowScope; workflowId: string; runId: string; + onAccessLost?: (status: number) => void; }) { const [expandedExecutionId, setExpandedExecutionId] = useState(null); const loadPage = useCallback((cursor: string | null, signal: AbortSignal) => fetchWorkflowExecutionsPage(scope, workflowId, runId, cursor, 50, signal), [runId, scope, workflowId]); - const page = usePagedResource(loadPage, 'Could not load workflow executions.'); + const page = usePagedResource(loadPage, 'Could not load workflow executions.', onAccessLost); return (
@@ -835,13 +861,17 @@ export function WorkflowExecutionHistory({ {resultSummary ? {resultSummary} : null}
+ {expanded ? ( execution.node_kind === 'for_each' ? : : ) : null} @@ -851,7 +881,7 @@ export function WorkflowExecutionHistory({ ) : null} - +
); } diff --git a/application/v2_ui/src/components/workflows/WorkflowPublicationDetails.tsx b/application/v2_ui/src/components/workflows/WorkflowPublicationDetails.tsx new file mode 100644 index 000000000..ce17fa981 --- /dev/null +++ b/application/v2_ui/src/components/workflows/WorkflowPublicationDetails.tsx @@ -0,0 +1,99 @@ +// WorkflowPublicationDetails.tsx +// The same allowlisted publication facts in a waiting gate and an exact attempt. + +import { + WORKFLOW_PUBLICATION_COMPLETION_LABELS, + type WorkflowPublicationStatus, +} from '../../lib/workflowEditor'; + +const stateLabels: Record = { + submitted: 'Publication submitted', + approved: 'Publication approval requirement met', + indexed_ready: 'Publication indexed and ready', + waiting_approval: 'Waiting for destination approval', + waiting_processing: 'Waiting for document processing', + waiting_screening: 'Waiting for content screening', + waiting_index: 'Waiting for search visibility', + uncertain: 'Publication outcome uncertain', + rejected: 'Destination publication rejected', + cancelled: 'Destination publication cancelled', + approval_failed: 'Destination approval failed', + processing_failed: 'Document processing failed', + unavailable: 'Publication readiness unavailable', + content_changed: 'Published content changed', +}; + +function factLabel(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1).replace(/_/g, ' '); +} + +export function WorkflowPublicationDetails({ + publication, + label = 'Publication status', +}: { + publication?: WorkflowPublicationStatus; + label?: string; +}) { + if (!publication) return null; + const destination = publication.destination; + const facts = [ + ['Requested completion', WORKFLOW_PUBLICATION_COMPLETION_LABELS[publication.completion_policy]], + ['Completion requirement', publication.policy_satisfied ? 'Met' : 'Not met'], + ['Submission', factLabel(publication.submission)], + ['Destination approval', factLabel(publication.approval)], + ['Processing', factLabel(publication.processing)], + ['Screening', factLabel(publication.screening)], + ['Index', factLabel(publication.index)], + ]; + const identifiers = [ + ['Receipt', publication.id], + ['Document', publication.document_id], + ['Document version', publication.document_version === null ? 'Not confirmed' : String(publication.document_version)], + ['Destination', `${factLabel(destination.workspace_scope)} workspace`], + ...(destination.group_id ? [['Group ID', destination.group_id]] : []), + ...(destination.public_workspace_id ? [['Public workspace ID', destination.public_workspace_id]] : []), + ]; + const waiting = publication.state.startsWith('waiting_'); + return ( +
+

+ {publication.policy_satisfied ? 'Saved completion observation: ' : ''}{stateLabels[publication.state]} +

+
+ {facts.map(([name, value]) => ( +
+
{name}
+
{value}
+
+ ))} +
+
+ {identifiers.map(([name, value]) => ( +
+
{name}:
+
{value}
+
+ ))} +
+ {publication.reason_code ?

Reason code: {publication.reason_code}

: null} + {publication.unresolved_stages.length ? ( +

Unresolved stages: {publication.unresolved_stages.map(factLabel).join(', ')}

+ ) : null} + {publication.state === 'waiting_approval' ? ( +

Destination reviewers decide in that workspace. Workflow task approval does not apply.

+ ) : null} + {publication.state === 'content_changed' ? ( +

The changed content cannot satisfy the original request. Cancel this run and start a new publication if needed.

+ ) : !publication.policy_satisfied && !waiting && !publication.retryable ? ( +

This receipt cannot meet the requested level. Cancel this run and start a new publication if needed.

+ ) : !publication.policy_satisfied ? ( +

Refresh reads status only. Resume / check again, when available, checks this receipt without publishing another copy or rerunning Analyze.

+ ) : ( +

+ These stages were saved when the requested completion level was met. Later destination changes do not update this snapshot. + {' '}Refresh rereads the saved observation; it does not confirm current destination approval, availability, or index readiness. +

+ )} +
+ ); +} diff --git a/application/v2_ui/src/components/workflows/WorkflowRunHistory.tsx b/application/v2_ui/src/components/workflows/WorkflowRunHistory.tsx index 1b728edf3..85c1e9ee9 100644 --- a/application/v2_ui/src/components/workflows/WorkflowRunHistory.tsx +++ b/application/v2_ui/src/components/workflows/WorkflowRunHistory.tsx @@ -1,7 +1,7 @@ // WorkflowRunHistory.tsx // Workflow run history and task-result inspection for V2 workflows. -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChevronDown, ChevronRight, FileJson, Loader2 } from 'lucide-react'; import { WorkflowExecutionHistory } from './WorkflowExecutionHistory'; import { WorkflowRuntimePanel } from './WorkflowRuntimePanel'; @@ -309,6 +309,10 @@ export function WorkflowRunHistory({ 'Failed to load run history.', ); const [expandedRunId, setExpandedRunId] = useState(null); + const [unavailableRun, setUnavailableRun] = useState<{ id: string | null; status: number } | null>(null); + const onAccessLost = useCallback((status: number) => { + setUnavailableRun({ id: expandedRunId, status }); + }, [expandedRunId]); const shown = useMemo(() => items.slice(0, 10), [items]); useEffect(() => { @@ -352,7 +356,10 @@ export function WorkflowRunHistory({ : } label={expanded ? 'Hide run task results' : 'Show run task results'} - onClick={() => setExpandedRunId(expanded ? null : runId)} + onClick={() => { + setExpandedRunId(expanded ? null : runId); + setUnavailableRun(null); + }} /> {status} @@ -365,7 +372,14 @@ export function WorkflowRunHistory({ {validation}

: null} {expanded ? ( - unsupportedDefinitionVersion ? ( + unavailableRun?.id === runId ? ( +

+ {unavailableRun.status === 403 + ? 'You no longer have access to this workflow run.' + : 'This workflow run history is no longer available.'} + {' '}Cached run details were removed. Reopen this run to check access again. +

+ ) : unsupportedDefinitionVersion ? (

This run uses workflow definition v{String(definitionVersion)}, which this inspector does not support yet.

@@ -377,13 +391,15 @@ export function WorkflowRunHistory({ runId={runId} durable={run.durable_execution === true} structuredRun={isStructuredRun} + onAccessLost={isStructuredRun ? onAccessLost : undefined} onRuntimeChanged={() => { void refresh(); onWorkflowRefresh?.(); }} /> {isStructuredRun ? ( - + ) : ( )} diff --git a/application/v2_ui/src/components/workflows/WorkflowRuntimePanel.tsx b/application/v2_ui/src/components/workflows/WorkflowRuntimePanel.tsx index 764318e8c..ebc91ce42 100644 --- a/application/v2_ui/src/components/workflows/WorkflowRuntimePanel.tsx +++ b/application/v2_ui/src/components/workflows/WorkflowRuntimePanel.tsx @@ -21,6 +21,7 @@ import { import { ConfirmDialog } from '../ui/ConfirmDialog'; import { GlassButton, GlassPanel } from '../ui/primitives'; import { Pill } from '../workspace/primitives'; +import { WorkflowPublicationDetails } from './WorkflowPublicationDetails'; function runtimeTone(state: string): 'ok' | 'warn' | 'danger' | 'neutral' | 'accent' { if (state === 'completed') { @@ -212,6 +213,7 @@ export function WorkflowRuntimePanel({ durable, structuredRun = false, onRuntimeChanged, + onAccessLost, }: { scope: WorkflowScope; workflowId: string; @@ -219,6 +221,7 @@ export function WorkflowRuntimePanel({ durable: boolean; structuredRun?: boolean; onRuntimeChanged?: () => void; + onAccessLost?: (status: number) => void; }) { const scopeKey = workflowScopeKey(scope); const [enabled, setEnabled] = useState(durable); @@ -266,7 +269,14 @@ export function WorkflowRuntimePanel({ return; } setCanDecide(false); - if (cause instanceof ApiError && (cause.status === 403 || cause.status === 404)) setRuntime(null); + setRuntime((current) => current?.gate?.publication ? null : current); + if (cause instanceof ApiError && (cause.status === 403 || cause.status === 404)) { + setRuntime(null); + setConfirmRetry(false); + setRetryTarget(null); + retryRequest.current = null; + onAccessLost?.(cause.status); + } if (cause instanceof ApiError && cause.status === 404) { setError('No durable runtime record is available for this run.'); } else if (cause instanceof ApiError && cause.status === 403) { @@ -280,7 +290,7 @@ export function WorkflowRuntimePanel({ setPollReadToken((value) => value + 1); } } - }, [runId, scope, scopeKey, workflowId]); + }, [onAccessLost, runId, scope, scopeKey, workflowId]); useEffect(() => { if (!enabled) { @@ -321,13 +331,15 @@ export function WorkflowRuntimePanel({ onRuntimeChanged?.(); }; - const clearRuntimeAfterPermissionLoss = () => { + const clearRuntimeAfterPermissionLoss = (status = 403) => { retryRequest.current = null; setRuntime(null); setCanDecide(false); setConfirmRetry(false); setRetryTarget(null); - setError('You no longer have access to this workflow runtime. Reload or ask an owner to restore access.'); + setError(status === 404 ? 'No durable runtime record is available for this run.' + : 'You no longer have access to this workflow runtime. Reload or ask an owner to restore access.'); + onAccessLost?.(status); }; const decide = async (choice: WorkflowRuntimeDecisionChoice) => { @@ -338,7 +350,8 @@ export function WorkflowRuntimePanel({ setError('The recovery gate changed while you were reviewing it. Review the current execution and attempt before retrying.'); return; } - if (!runtime?.gate || !canDecide || !runtime.gate.choices.includes(choice)) { + if (!runtime?.gate || !canDecide || !runtime.gate.choices.includes(choice) || + runtime.gate.publication && ['approve', 'reject', 'retry'].includes(choice)) { setError('The gate is no longer available. Reload this run before making another decision.'); return; } @@ -372,8 +385,8 @@ export function WorkflowRuntimePanel({ retryRequest.current = null; await loadRuntime(true); setError('Runtime changed before your decision was applied. Review the current gate and click again.'); - } else if (cause instanceof ApiError && cause.status === 403) { - clearRuntimeAfterPermissionLoss(); + } else if (cause instanceof ApiError && (cause.status === 403 || cause.status === 404)) { + clearRuntimeAfterPermissionLoss(cause.status); } else { const message = cause instanceof Error && cause.message ? cause.message @@ -407,8 +420,8 @@ export function WorkflowRuntimePanel({ retryRequest.current = null; await loadRuntime(true); setError('Runtime changed before your resume request was applied. Review the current runtime and click again.'); - } else if (cause instanceof ApiError && cause.status === 403) { - clearRuntimeAfterPermissionLoss(); + } else if (cause instanceof ApiError && (cause.status === 403 || cause.status === 404)) { + clearRuntimeAfterPermissionLoss(cause.status); } else { const message = cause instanceof Error && cause.message ? cause.message @@ -432,7 +445,9 @@ export function WorkflowRuntimePanel({ await loadRuntime(true); onRuntimeChanged?.(); } catch (cause: unknown) { - setError(workflowErrorMessage(cause, 'Could not cancel this workflow run.')); + if (cause instanceof ApiError && (cause.status === 403 || cause.status === 404)) { + clearRuntimeAfterPermissionLoss(cause.status); + } else setError(workflowErrorMessage(cause, 'Could not cancel this workflow run.')); } finally { setAction(null); } @@ -441,7 +456,8 @@ export function WorkflowRuntimePanel({ const gate = runtime?.gate; const unsupportedRuntimeSchema = Boolean(runtime && runtime.schema_version !== undefined && ![1, 2].includes(runtime.schema_version)); const gateAllows = (choice: WorkflowRuntimeDecisionChoice) => - Boolean(gate?.choices.includes(choice)); + Boolean(gate?.choices.includes(choice) && + (!gate.publication || !['approve', 'reject', 'retry'].includes(choice))); const progressLabel = useMemo(() => { if (!runtime?.progress) { return ''; @@ -510,9 +526,13 @@ export function WorkflowRuntimePanel({
{gateReference(gate) ?

{gateReference(gate)}

: null} {gate.reason ?

{gate.reason}

: null} - {gate.input_digest ?

Input digest: {gate.input_digest}

: null} + + {gate.input_digest && !gate.publication ?

Input digest: {gate.input_digest}

: null} {gate.kind === 'output' ? ( -

Waiting for required output. Approval and retry are not available for this gate.

+

+ {gate.publication ? 'Waiting for the requested publication completion level.' : 'Waiting for required output.'} + {' '}Approval and retry are not available for this gate. +

) : null} {canMutate && gate.kind === 'approval' ? (
@@ -556,7 +576,7 @@ export function WorkflowRuntimePanel({ {gateAllows('resume') ? ( void decide('resume')}> {action === 'resume' ? : } - Resume run + {gate.publication ? 'Resume / check again' : 'Resume run'} ) : null} {gateAllows('cancel') ? ( diff --git a/application/v2_ui/src/lib/workflowEditor.ts b/application/v2_ui/src/lib/workflowEditor.ts index a64169cea..0d6b5ecf2 100644 --- a/application/v2_ui/src/lib/workflowEditor.ts +++ b/application/v2_ui/src/lib/workflowEditor.ts @@ -71,6 +71,7 @@ export interface WorkflowEditorOptions { supported_query_modes?: string[]; supported_binding_sources?: string[]; supported_input_processing_modes?: string[]; + supported_publication_completion_policies?: string[]; flow_limits?: { max_nodes: number; max_depth: number; @@ -131,6 +132,90 @@ export interface WorkflowPublication { workspace_scope: 'personal' | 'group' | 'public'; group_id?: string; public_workspace_id?: string; + completion_policy?: WorkflowPublicationCompletionPolicy; +} + +export const WORKFLOW_PUBLICATION_COMPLETION_LABELS = { + submitted: 'Submitted', + approved: 'Approved', + indexed_ready: 'Indexed and ready', +} as const; + +export type WorkflowPublicationCompletionPolicy = keyof typeof WORKFLOW_PUBLICATION_COMPLETION_LABELS; + +export function isWorkflowPublicationCompletionPolicy(value: unknown): value is WorkflowPublicationCompletionPolicy { + return typeof value === 'string' && Object.hasOwn(WORKFLOW_PUBLICATION_COMPLETION_LABELS, value); +} + +const PUBLICATION_FACT_VALUES = { + state: [ + 'submitted', 'approved', 'indexed_ready', 'waiting_approval', 'waiting_processing', + 'waiting_screening', 'waiting_index', 'uncertain', 'rejected', 'cancelled', + 'approval_failed', 'processing_failed', 'unavailable', 'content_changed', + ], + submission: ['pending', 'confirmed', 'uncertain'], + approval: ['not_required', 'pending', 'approved', 'rejected', 'cancelled', 'failed'], + processing: ['not_started', 'queued', 'running', 'complete', 'failed', 'unavailable'], + screening: ['not_required', 'pending', 'held', 'available', 'rejected', 'changed', 'unavailable'], + index: ['pending', 'ready', 'unavailable'], +} as const; + +export interface WorkflowPublicationStatus { + version: 1; + id: string; + document_id: string; + document_version: number | null; + destination: { + workspace_scope: WorkflowReferenceScope; + group_id?: string; + public_workspace_id?: string; + }; + completion_policy: WorkflowPublicationCompletionPolicy; + policy_satisfied: boolean; + state: typeof PUBLICATION_FACT_VALUES.state[number]; + submission: typeof PUBLICATION_FACT_VALUES.submission[number]; + approval: typeof PUBLICATION_FACT_VALUES.approval[number]; + processing: typeof PUBLICATION_FACT_VALUES.processing[number]; + screening: typeof PUBLICATION_FACT_VALUES.screening[number]; + index: typeof PUBLICATION_FACT_VALUES.index[number]; + reason_code: string; + retryable: boolean; + unresolved_stages: string[]; +} + +export function isWorkflowPublicationStatus(value: unknown): value is WorkflowPublicationStatus { + const identity = (item: unknown): item is string => + typeof item === 'string' && item.length > 0 && item.length <= 256 && item === item.trim(); + const code = (item: unknown): item is string => + typeof item === 'string' && /^[a-z][a-z0-9_]{0,127}$/.test(item); + if (!isRecord(value) || value.version !== 1 || + Object.keys(value).some((key) => ![ + 'version', 'id', 'document_id', 'document_version', 'destination', 'completion_policy', + 'policy_satisfied', 'state', 'submission', 'approval', 'processing', 'screening', + 'index', 'reason_code', 'retryable', 'unresolved_stages', + ].includes(key)) || + typeof value.id !== 'string' || !/^[a-f0-9]{64}$/.test(value.id) || + !identity(value.document_id) || + value.document_version !== null && (typeof value.document_version !== 'number' || + !Number.isSafeInteger(value.document_version) || value.document_version < 1) || + !isWorkflowPublicationCompletionPolicy(value.completion_policy) || + typeof value.policy_satisfied !== 'boolean' || typeof value.retryable !== 'boolean' || + value.reason_code !== '' && !code(value.reason_code) || + !Array.isArray(value.unresolved_stages) || value.unresolved_stages.length > 32 || + !value.unresolved_stages.every(code) || + Object.entries(PUBLICATION_FACT_VALUES).some(([key, allowed]) => { + const fact = value[key]; + return typeof fact !== 'string' || !(allowed as readonly string[]).includes(fact); + }) || + !isRecord(value.destination)) return false; + const destination = value.destination; + const scope = destination.workspace_scope; + return (scope === 'personal' || scope === 'group' || scope === 'public') && + Object.keys(destination).every((key) => + key === 'workspace_scope' || scope === 'group' && key === 'group_id' || + scope === 'public' && key === 'public_workspace_id') && + (scope !== 'group' || identity(destination.group_id)) && + (scope !== 'public' || identity(destination.public_workspace_id)); } export interface WorkflowTask { @@ -208,6 +293,7 @@ export interface WorkflowRuntimeGate { node_id?: string; attempt?: number; iteration_path?: WorkflowIterationFrame[]; + publication?: WorkflowPublicationStatus; } export interface WorkflowIterationFrame { @@ -1000,8 +1086,6 @@ export function workflowValidationErrors( } if (draft.definition_version === 3) { errors.push(...analyzeWorkflowFlow(draft).errors); - const unsupported = flowUnsupportedReason(draft, options); - if (unsupported) errors.push(unsupported); flowLoops(draft).forEach(({ node }) => { errors.push(...loopSelectionErrors(node, workflowLoopLimit(options))); const sources = node.iterable.kind === 'documents' ? node.iterable.documents @@ -1030,6 +1114,8 @@ export function workflowValidationErrors( }; checkCollects(draft.flow); } + const unsupported = flowUnsupportedReason(draft, options); + if (unsupported) errors.push(unsupported); if (draft.trigger_type === 'interval' && draft.schedule.value < 1) { errors.push('Interval workflows need a positive schedule value.'); } @@ -1224,7 +1310,7 @@ export async function fetchWorkflowEditorOptions( const ceiling = response.flow_limits?.max_loop_items; if (ceiling !== undefined && (!Number.isInteger(ceiling) || ceiling < 1 || ceiling > 5000) || [response.supported_node_kinds, response.supported_iterable_kinds, response.supported_query_modes, response.supported_binding_sources, - response.supported_input_processing_modes] + response.supported_input_processing_modes, response.supported_publication_completion_policies] .some((values) => values !== undefined && (!Array.isArray(values) || values.some((value) => typeof value !== 'string'))) || [...response.agents, ...response.models, response.default_model ?? {}] .some((runner) => !isRecord(runner) || runner.loop_eligible !== undefined && typeof runner.loop_eligible !== 'boolean')) { @@ -1413,6 +1499,10 @@ function checkedRuntimeResponse(response: WorkflowRuntimeResponse): WorkflowRunt if (!response?.runtime || paths.some((path) => path !== undefined && !validWorkflowIterationPath(path))) { throw new Error('The workflow runtime contains an unsupported iteration identity. Reload before making a decision.'); } + const publication = response.runtime.gate?.publication; + if (publication !== undefined && !isWorkflowPublicationStatus(publication)) { + throw new Error('The workflow runtime returned an unsupported publication status. Reload before making a decision.'); + } const progress = response.runtime.loop_progress; if (progress && ( typeof progress.loop_id !== 'string' || !progress.loop_id.trim() || diff --git a/application/v2_ui/src/lib/workflowExecutionHistory.ts b/application/v2_ui/src/lib/workflowExecutionHistory.ts index beae753ea..394e290e7 100644 --- a/application/v2_ui/src/lib/workflowExecutionHistory.ts +++ b/application/v2_ui/src/lib/workflowExecutionHistory.ts @@ -7,10 +7,12 @@ import { workflowUrl, workflowLoopSelection, validWorkflowIterationPath, + isWorkflowPublicationStatus, type WorkflowConsumedInput, type WorkflowIterationFrame, type WorkflowLoopSelection, type WorkflowResultReference, + type WorkflowPublicationStatus, type WorkflowRunResultPage, type WorkflowScope, type WorkflowValidationResult, @@ -50,6 +52,7 @@ export interface WorkflowExecutionRecord { authoritative_output?: string; consumed_inputs?: WorkflowConsumedInput[]; reporting?: unknown; + publication?: WorkflowPublicationStatus; }; workflow_validation?: WorkflowValidationResult; consumed_inputs?: WorkflowConsumedInput[]; @@ -73,6 +76,7 @@ export interface WorkflowExecutionAttemptRecord { authoritative_output?: string; consumed_inputs?: WorkflowConsumedInput[]; reporting?: unknown; + publication?: WorkflowPublicationStatus; }; workflow_validation?: WorkflowValidationResult; consumed_inputs?: WorkflowConsumedInput[]; @@ -207,7 +211,9 @@ function validInputs(value: unknown): boolean { function validResultMetadata(value: Record): boolean { return validInputs(value.consumed_inputs) && - (value.workflow_result === undefined || isRecord(value.workflow_result) && validInputs(value.workflow_result.consumed_inputs)) && + (value.workflow_result === undefined || isRecord(value.workflow_result) && + validInputs(value.workflow_result.consumed_inputs) && + (value.workflow_result.publication === undefined || isWorkflowPublicationStatus(value.workflow_result.publication))) && (value.workflow_validation === undefined || isRecord(value.workflow_validation)); } diff --git a/application/v2_ui/src/lib/workflowFlow.ts b/application/v2_ui/src/lib/workflowFlow.ts index dc33ed515..0acc2e0cb 100644 --- a/application/v2_ui/src/lib/workflowFlow.ts +++ b/application/v2_ui/src/lib/workflowFlow.ts @@ -567,6 +567,20 @@ function onlyFields(value: object, fields: string[], errors: string[], label: st export function flowUnsupportedReason(workflow: WorkflowDefinition, options?: WorkflowEditorOptions): string { if (workflow.editor_readonly_reason) return workflow.editor_readonly_reason; + for (const task of workflow.tasks) { + const publication = task.publication; + if (!isRecord(publication) || !Object.hasOwn(publication, 'completion_policy')) continue; + const policy = publication.completion_policy; + if (typeof policy !== 'string' || !['submitted', 'approved', 'indexed_ready'].includes(policy)) { + return 'This publication contains an unsupported completion policy. Its original configuration is preserved and read-only.'; + } + if (workflow.definition_version !== 3 || workflow.durable_execution !== true) { + return 'Publication completion policies require a durable definition-v3 workflow. The saved definition is preserved and read-only.'; + } + if (options && !options.supported_publication_completion_policies?.includes(policy)) { + return 'This server does not support the saved publication completion policy. Its original configuration is preserved and read-only.'; + } + } if (workflow.definition_version !== 3) return ''; if (!isFlowRegion(workflow.flow)) return 'This structured definition contains an unsupported or malformed region, node, binding, or condition.'; if (!Array.isArray(workflow.flow.outputs)) return 'This structured definition must explicitly declare root outputs, including an empty list. Its saved definition is preserved.'; diff --git a/docs/admin/workflow.md b/docs/admin/workflow.md index c8fbed899..94b2fe591 100644 --- a/docs/admin/workflow.md +++ b/docs/admin/workflow.md @@ -123,6 +123,23 @@ for retained-data behavior, partial coverage, and inspection. ## Common tasks +### Publication completion + +Version **0.261.118** lets authors of version-3 durable workflows choose +Submitted, Approved, or Indexed and ready for an existing native Analyze +artifact. This is a task option, not another administrator toggle. Existing +workflows without a policy retain their prior behavior. + +Publication waits use the existing scheduler and lease. They keep the run +active and count against its existing elapsed deadline. Unmet policies cannot +be skipped with continue-on-error; neither Resume nor a changed administrator +limit resets the active run's admitted bounds. Destination approval and content +screening retain their existing permissions. + +See [Workflow publication completion](../explanation/features/WORKFLOW_PUBLICATION_COMPLETION.md). + +### Administration examples + 1. **Pilot group workflows with one team.** Enable Group Workflows, turn on Require Group Assignment to Use Workflow, then assign only the pilot group. Outcome to verify: members of the pilot group see the Workflows section in diff --git a/docs/explanation/features/ANALYZE_RESULTS.md b/docs/explanation/features/ANALYZE_RESULTS.md index 280332009..56ae09ecf 100644 --- a/docs/explanation/features/ANALYZE_RESULTS.md +++ b/docs/explanation/features/ANALYZE_RESULTS.md @@ -101,6 +101,13 @@ reconstruction. A saved receipt identifies the request, source projection, and destination. A retry reconciles an existing document or pending approval; an uncertain outcome is not treated as permission to create a second copy. +Version **0.261.118** adds an optional +[workflow publication completion policy](WORKFLOW_PUBLICATION_COMPLETION.md) +for existing native Analyze artifacts. A version-3 durable workflow may require +confirmed submission, approval, or exact destination index readiness before +continuing. The absent policy retains its previous behavior. This does not +turn generic saved results or Collect outputs into native Analyze artifacts. + Already published workspace copies have their own destination permissions and lifecycle. They do not inherit later access changes to the original sources. Previously delivered or downloaded bytes cannot be recalled. diff --git a/docs/explanation/features/WORKFLOW_PUBLICATION_COMPLETION.md b/docs/explanation/features/WORKFLOW_PUBLICATION_COMPLETION.md new file mode 100644 index 000000000..e3344638b --- /dev/null +++ b/docs/explanation/features/WORKFLOW_PUBLICATION_COMPLETION.md @@ -0,0 +1,193 @@ +# Workflow publication completion + +Implemented in version: **0.261.118**. + +Application version tracking: `application/single_app/config.py`. + +## Purpose and scope + +A workflow can submit an existing Analyze artifact without waiting for it to +be searchable, or require approval and index readiness before continuing. +The completion policy makes that choice explicit. Closing the browser or +restarting a worker does not create a new publication request. + +This feature applies to **definition-version-3 durable workflows** in personal +and group workspaces. It reuses native Analyze artifacts, the existing +publication receipt ledger, workspace approval routes, native document +processing, screening, and the existing workflow runner. It adds no scheduler, +Cosmos container, administrator setting, or model call. + +Generic saved results and Collect outputs cannot be relabeled as native +Analyze artifacts. Publishing those representations is outside this feature. + +## Choose the completion level + +In the V2 List editor, configure a task to **Publish an existing analysis +artifact**. Bind exactly one eligible native Analyze output, choose an existing +artifact format and an explicit destination, then select **Complete publication +when**. + +| Level | When the workflow can continue | What remains outside the promise | +| --- | --- | --- | +| Submitted | The destination request and required submission stages are confirmed. Personal publication has a processing handoff; group/public publication has an approval request. | Approval, completed processing, and search readiness. | +| Approved | The existing destination approval has completed, including its processing handoff. Personal workspaces report approval as **not required** and complete at confirmed submission. | Completed processing, screening clearance, and search readiness. | +| Indexed and ready | The original-content destination revision completed its native processing, is currently available under screening rules, and its complete expected native index projection is visible. | Future availability or guaranteed semantic relevance. | + +For example, choose **Submitted** when the deliverable only needs to enter a +review queue. Choose **Indexed and ready** when a later task relies on the +published document being available for workspace retrieval. + +New publication tasks select Submitted when the server advertises support. +Existing tasks without a completion policy display their existing behavior +and keep the field absent until the author changes it. Their prior behavior +is not silently converted into a new policy. + +Destination approval is separate from both a workflow's optional +pre-execution task approval and a content-screening review. Choosing Approved +does not manufacture a personal-workspace approval step or authorize the +workflow to approve its own shared destination. + +## Definition and receipt identity + +The policy is one optional enum on the existing publication object: + +```json +{ + "publication": { + "artifact_format": "md", + "workspace_scope": "group", + "group_id": "explicit-destination-id", + "completion_policy": "indexed_ready" + } +} +``` + +Allowed values are `submitted`, `approved`, and `indexed_ready`. Null and +unknown values are rejected. Version-1/2 definitions do not support this +field. Editor options advertise `supported_publication_completion_policies`; +unsupported saved definitions remain intact and read-only. + +The existing receipt binds the artifact, immutable byte digest, exact native +producer execution/attempt, selected saved output, destination, actor and +policy. The publishing execution includes the run, definition revision and +loop path. Retrying that publishing execution does not create a new document +merely because its publishing attempt changed. + +A private `artifact_publication` continuation points back to this receipt. +It is persisted with the existing task checkpoint, not in a second job +system. IDs, previews and receipts do not confer permissions. + +## Waiting and recovery + +Run inspection shows the requested level and separate submission, approval, +processing, screening and index observations. The waiting task remains +ineligible for downstream use. + +| Observation | Workflow behavior | +| --- | --- | +| Destination approval pending | Waits for the existing workspace review. Workflow task Approve/Reject controls do not decide this request. | +| Native processing, screening/review, or index visibility pending | Remains waiting for output and releases the worker. | +| A remote operation may have happened but its acknowledgement is missing | Pauses with the existing receipt. Rechecking does not blindly repeat the operation. | +| Source or destination access cannot be confirmed | Withholds sensitive details and pauses; restore access before rechecking. | +| Rejection, request cancellation, unavailable destination, or unsupported readiness | Does not report success or create a replacement copy. | +| Screening changes/remediates the original content | Pauses rather than silently accepting the altered derivative. | + +Use the existing workspace review surface for destination decisions. Where +the runtime offers Resume/check again, it examines the same publication. +Confirmed copies and notifications are not resent. An unconfirmed started +effect without durable evidence remains uncertain, even after repeated Resume. + +An explicit policy cannot be skipped by **continue on error**. Waiting time +counts toward the existing elapsed deadline, and Resume does not reset that +deadline or execution limits. A scheduled trigger does not overlap the waiting +run. + +Once a requested weaker level has successfully completed, later destination +lifecycle changes do not retroactively reopen that task. Its saved result +records the level actually achieved; Submitted never means Indexed and ready. + +A saved successful completion observation is immutable. Recovery rechecks +current source/artifact/destination authorization separately instead of +rewriting that observation when approval or processing advances. Pending +observations can still refresh within the same exact attempt. + +## What proves index readiness + +The observer checks the exact destination document, receipt and native +revision, not a filename or one convenient search hit. Native ingestion records +the original input binding and completed indexed-chunk count. The observer +requires the full expected count in the exact document/version/destination +scope and rechecks current availability. + +Both fresh availability reads reject a revision that has been archived or +replaced while readiness was being checked. Permission to read a historical +copy does not make it the current searchable publication. + +Screened documents additionally require the native scan/release proof for +unchanged content. Pending review, incomplete scanning, errors and stale +clearance cannot qualify. Ordinary clearance or approval-with-flags may qualify +when the published bytes are unchanged. + +"Processing complete - no content indexed" is not index readiness. The native +projection is format-specific: a tabular document may have a schema-oriented +search representation rather than an individual search chunk for every row. +This policy does not replace the native indexing contract with a promise of +exhaustive semantic retrieval. + +If complete native proof is unavailable, the workflow reports that limitation; +it does not reconstruct an artifact with a model or infer success from prose. + +Reconciliation does not make an underlying native upload resumable or blindly +requeue a lost native job. If an acknowledged handoff produces no completion +evidence, the workflow remains waiting within its existing deadline. An +unacknowledged operation remains uncertain until its exact outcome can be +confirmed. + +An already-enqueued native screening job can finish independently of the upload +worker. Its authoritative, unchanged-content release proof can confirm the +original handoff even if that worker never recorded a processing marker. + +## Authorization and cancellation + +Current workflow, source, artifact and destination access is checked at +sensitive operations and continuation. The recorded initiating actor remains +the publisher, including group workflows where that actor differs from the +workflow owner. Another manager's Resume does not transfer their permissions +to the run. + +Destination reviewers use their existing roles. Positive approval rechecks +the original publisher's source/destination authority and the original bytes. +A rejection or cancellation retains a receipt outcome before the pending +document is removed. + +Workflow viewers do not inherit access to a private personal destination. +Public status projections exclude internal artifact locators and processing +evidence; normal document mutations cannot supply that evidence. + +Cancel stops subsequent workflow work and fenced checkpoint writes. It cannot +undo a native processing job, copy or notification already handed off. Published +workspace copies have independent destination permissions and are not deleted +when a workflow or its private run results are deleted. + +## Implementation and validation + +| Component | Responsibility | +| --- | --- | +| `functions_artifact_publication.py` | Existing receipt stages, policy evaluation, authorization and shared destination decisions. | +| `functions_artifact_publication_readiness.py` | Compact native ingestion evidence and read-only index/availability observations. | +| `functions_workflow_readiness.py` and existing runner/runtime | Exact typed continuation, wait/requeue and same-receipt recovery. | +| Group/public document routes | Existing approval, denial and cancellation surfaces. | +| V2 workflow editor and run inspectors | Completion choice and safe, exact execution/attempt status. | + +Regression coverage includes real native Analyze adaptation through a saved +join; personal/group/public publication; both Cosmos and Blob result storage; +delayed approval/index visibility; restart and repeated Resume; screening and +original-byte checks; lost acknowledgements; source/destination revocation; +notification deduplication; and no-policy compatibility. + +Principal tests are `test_workflow_publication_completion.py`, +`test_workflow_structured_publication.py`, `test_publication_native_processing.py` +and `ui_tests/test_v2_workflow_publication_completion.py`. They use closed +fictional service boundaries and the actual local V2 bundle. They do not +constitute live deployment acceptance or publish test documents to real +workspaces. diff --git a/docs/explanation/features/WORKFLOW_STRUCTURED_CONTROL_FLOW.md b/docs/explanation/features/WORKFLOW_STRUCTURED_CONTROL_FLOW.md index 10cfc8e86..83f991d39 100644 --- a/docs/explanation/features/WORKFLOW_STRUCTURED_CONTROL_FLOW.md +++ b/docs/explanation/features/WORKFLOW_STRUCTURED_CONTROL_FLOW.md @@ -2,7 +2,7 @@ Implemented in version: **0.261.116** -Updated in version: **0.261.117**. +Updated in version: **0.261.118**. Application version tracking: `application/single_app/config.py`. @@ -207,8 +207,12 @@ second document or duplicate approval notifications. Existing `queued`, `pending_approval`, `approved`, `approval_failed`, and `uncertain` states retain their meanings; none is a new promise that indexing has completed. -New processing/index-readiness policies and publication adapters for generic -aggregates belong to later milestone-4 slices. +Version **0.261.118** adds an optional +[publication completion policy](WORKFLOW_PUBLICATION_COMPLETION.md) for existing +native Analyze artifacts: Submitted, Approved, or Indexed and ready. Unmet +policies retain the existing receipt and wait or pause rather than creating +another copy. Omitted policies retain the previous behavior. Publication +adapters for generic aggregates remain a separate slice. ## Regression coverage and boundaries diff --git a/docs/guides/create-a-workflow.md b/docs/guides/create-a-workflow.md index dd730132e..ade80d5f3 100644 --- a/docs/guides/create-a-workflow.md +++ b/docs/guides/create-a-workflow.md @@ -182,6 +182,20 @@ Group and public copies retain their approval process. An uncertain publication shows the existing destination/receipt instead of blindly creating another copy. Once explicitly published, the copy follows the destination's access rules. +Starting in **0.261.118**, a version-3 durable publication task can choose +**Complete publication when**: **Submitted**, **Approved**, or **Indexed and +ready**. Use Submitted to hand a deliverable into a review queue; use Indexed +and ready when the next step depends on workspace retrieval. Personal +workspaces do not have a destination approval gate, so Approved reports +approval as not required. + +New publication tasks default to Submitted. Existing tasks retain their +previous behavior until you explicitly choose a policy. Queued, approved and +indexed-ready are different stages; a failed or uncertain explicit policy +pauses instead of publishing another copy or continuing on error. See +[Workflow publication completion](../explanation/features/WORKFLOW_PUBLICATION_COMPLETION.md) +for readiness proof, screening and recovery limitations. + ## Troubleshooting | Symptom | Likely cause | Fix | diff --git a/docs/guides/trigger-a-workflow.md b/docs/guides/trigger-a-workflow.md index 8ecba2f04..8bbd74ba8 100644 --- a/docs/guides/trigger-a-workflow.md +++ b/docs/guides/trigger-a-workflow.md @@ -111,6 +111,27 @@ See [Serial For each and exact Collect](../explanation/features/WORKFLOW_FOR_EAC ## Troubleshooting +### A publication is waiting + +In **0.261.118**, version-3 durable publication tasks can wait for a chosen +completion level. Inspect the requested level and the separate submission, +approval, processing, screening and index observations in run details. + +**Waiting for destination approval** means the request is in the existing +workspace review, not that the workflow's task-approval button can approve it. +If processing or indexing is pending, the run retains its exact receipt and +does not submit Analyze or another document just to check progress. + +For uncertain effects or restored access, use Resume/check again only when +offered. It rechecks the existing receipt. A rejected request or changed +original content cannot silently satisfy the policy; cancel and start a new +authorized request where appropriate. The elapsed deadline still includes +these waits. + +See [Workflow publication completion](../explanation/features/WORKFLOW_PUBLICATION_COMPLETION.md). + +### Other run problems + | Symptom | Likely cause | Fix | | --- | --- | --- | | A scheduled workflow does not run | It is disabled or still configured for manual trigger | Edit the trigger and confirm the workflow is enabled. | diff --git a/functional_tests/test_analysis_artifact_publication.py b/functional_tests/test_analysis_artifact_publication.py index cceee6e54..98e264763 100644 --- a/functional_tests/test_analysis_artifact_publication.py +++ b/functional_tests/test_analysis_artifact_publication.py @@ -1,7 +1,7 @@ # test_analysis_artifact_publication.py """ Functional tests for explicit existing-artifact publication and retry receipts. -Version: 0.261.109 +Version: 0.261.118 Implemented in: 0.261.109 Exercise real publication, normalization, and route bodies with Cosmos/queue @@ -32,6 +32,7 @@ APP = Path(__file__).resolve().parents[1] / "application" / "single_app" saved_analysis = import_app_module("functions_saved_analysis") +definitions = import_app_module("functions_workflow_definitions") def load_functions(filename, names, namespace): @@ -52,6 +53,7 @@ def normalizers(): "WORKFLOW_TASK_LIMIT_MAX": 100, "WORKFLOW_MAX_TASKS": 50, "WORKFLOW_TASK_INSTRUCTIONS_MAX_LENGTH": 12000, "WORKFLOW_TASK_NAME_MAX_LENGTH": 120, "WORKFLOW_TASK_RUNNER_TYPES": {"inherit", "agent", "model"}, + "normalize_publication_completion_policy": definitions.normalize_publication_completion_policy, }) @@ -154,7 +156,7 @@ def create_document(**values): state["create_hook"]() if state["failure"] == "create_before": raise TimeoutError("Create not acknowledged") - destination_for(values).put({"id": values["document_id"], **values}) + destination_for(values).put({"id": values["document_id"], "version": 1, **values}) if state["failure"] == "create_after": raise TimeoutError("Create acknowledgement lost") diff --git a/functional_tests/test_content_screening_pipeline.py b/functional_tests/test_content_screening_pipeline.py index e981745a4..284a990bd 100644 --- a/functional_tests/test_content_screening_pipeline.py +++ b/functional_tests/test_content_screening_pipeline.py @@ -1,9 +1,10 @@ # test_content_screening_pipeline.py """ Functional integration tests for workspace admission and reviewed publication. -Version: 0.261.114 +Version: 0.261.118 Implemented in: 0.261.106 Enabled-empty upload admission implemented in: 0.261.114 +Publication processing evidence implemented in: 0.261.118 Runs the real durable job, scanner, repository, private storage, TXT extraction, and publication services against fake Azure boundaries. No live data is used. @@ -23,7 +24,7 @@ import pytest from azure.core import MatchConditions -from azure.core.exceptions import ResourceExistsError, ResourceModifiedError, ResourceNotFoundError +from azure.core.exceptions import AzureError, ResourceExistsError, ResourceModifiedError, ResourceNotFoundError from flask import Flask, session @@ -50,6 +51,10 @@ from content_screening.storage import ScreeningStorage import functions_embedding_compatibility as embedding_compatibility from functions_embeddings import EmbeddingVector +from functions_artifact_publication_readiness import ( + PUBLICATION_BINDING, begin_publication_processing, finish_publication_processing, + inspect_publication_readiness, +) from test_content_screening_persistence import FakeBlob, FakeBlobContainer, FakeBlobService, FakeCosmos, FakeSdkError @@ -238,10 +243,14 @@ def delete_chunks(document_id, **kwargs): "os": os, "math": math, "logging": logging, "datetime": datetime, "timezone": timezone, "current_extraction": current_extraction, "ScreeningError": ScreeningError, "get_settings": lambda: settings, + "AzureError": AzureError, "get_chunk_size_config": lambda value=None: {"txt": {"value": 3}}, "get_document_metadata": get_metadata, "update_document": update_document, "document_requires_screening": service.document_requires_screening, "process_screened_upload": service.process_screened_upload, + "PUBLICATION_BINDING": PUBLICATION_BINDING, + "begin_publication_processing": begin_publication_processing, + "finish_publication_processing": finish_publication_processing, "SCREENING_FIELD": SCREENING_FIELD, "DocumentHeldError": DocumentHeldError, "is_publication": is_publication, "subject_from_document": service.subject_from_document, "cosmos_user_documents_container": containers["personal"], diff --git a/functional_tests/test_publication_native_processing.py b/functional_tests/test_publication_native_processing.py new file mode 100644 index 000000000..71174e82f --- /dev/null +++ b/functional_tests/test_publication_native_processing.py @@ -0,0 +1,69 @@ +# test_publication_native_processing.py +""" +Native document processing evidence for workflow publication completion. +Version: 0.261.118 +Implemented in: 0.261.118 + +The real upload dispatcher, chunk writes and screening release run against the +shared closed pipeline fixture. Readiness never invokes a second native job. +""" + +import hashlib + +import pytest + +from test_content_screening_pipeline import pipeline, save_empty_baseline # noqa: F401 +from content_screening import access, service +from content_screening.contracts import SCREENING_FIELD, Subject +from functions_artifact_publication_readiness import ( + PUBLICATION_BINDING, PUBLICATION_PROCESSING, inspect_publication_readiness, publication_handoff_observed, +) + + +@pytest.mark.parametrize("screened,bypass_wrapper", [(False, False), (True, False), (True, True)]) +def test_real_native_completion_proves_the_exact_receipt_projection(pipeline, tmp_path, screened, bypass_wrapper): + if not screened: + save_empty_baseline(pipeline) + content = b"Ordinary original artifact content. All of these words must reach the native index." + receipt = { + "id": "a" * 64, "document_id": "publication-document", "document_version": 1, + "actor_user_id": "owner", "destination": {"workspace_scope": "personal"}, + "content_sha256": hashlib.sha256(content).hexdigest(), + "artifact_reference": {"conversation_id": "conversation", "artifact_message_id": "artifact"}, + } + document = { + "id": receipt["document_id"], "user_id": "owner", "version": 1, "file_name": "artifact.txt", + "is_current_version": True, "upload_date": "2026-09-18T17:00:00Z", + "num_chunks": 0, "number_of_pages": 0, + "generated_artifact_publication_receipt_id": receipt["id"], + PUBLICATION_BINDING: { + "version": 1, "receipt_id": receipt["id"], "document_version": 1, + "content_sha256": receipt["content_sha256"], **receipt["artifact_reference"], + }, + } + marker = service.initial_document_marker(document) + if marker is not None: + document[SCREENING_FIELD] = marker + pipeline.repository.document_container("personal").create_item(document) + source = tmp_path / "artifact.txt" + source.write_bytes(content) + arguments = {"document_id": document["id"], "user_id": "owner", "temp_file_path": str(source), "original_filename": source.name} + if bypass_wrapper: + service.prepare_document_upload(**arguments) + service.process_screened_upload(**arguments, processor=pipeline.helpers._process_document_upload_background_impl) + else: + pipeline.helpers.process_document_upload_background(**arguments) + current = pipeline.repository.read_document(Subject("personal", "owner", document["id"], "1")) + indexed = [ + chunk for chunk in pipeline.search.documents.values() + if chunk.get("document_id") == document["id"] and chunk.get("version") == 1 and chunk.get("user_id") == "owner" + ] + assert indexed + observed = inspect_publication_readiness(receipt, current, index_count=lambda *args: len(indexed)) + assert observed["processing"] == "complete" and observed["index"] == "ready" + assert observed["screening"] == ("available" if screened else "not_required") + if bypass_wrapper: + assert PUBLICATION_PROCESSING not in current + assert publication_handoff_observed(receipt, current) + public = access.public_document_payload(current) + assert PUBLICATION_BINDING not in public and "generated_artifact_publication_processing" not in public diff --git a/functional_tests/test_support/workflow_results.py b/functional_tests/test_support/workflow_results.py index 8f53e83d7..8abb74698 100644 --- a/functional_tests/test_support/workflow_results.py +++ b/functional_tests/test_support/workflow_results.py @@ -53,6 +53,7 @@ WorkflowOutputUnavailable, pending_workflow_output_references, reconcile_workflow_pending_output, + reconcile_workflow_publication_output, ) from functions_workflow_runtime_store import WorkflowRuntimeConflict @@ -88,6 +89,7 @@ def load_result(workflow, run_id, task_id, reference): "WorkflowOutputUnavailable": WorkflowOutputUnavailable, "pending_workflow_output_references": pending_workflow_output_references, "reconcile_workflow_pending_output": reconcile_workflow_pending_output, + "reconcile_workflow_publication_output": reconcile_workflow_publication_output, "attach_workflow_reference_sources": attach_workflow_reference_sources, "load_workflow_reference": load_workflow_reference, "resolve_workflow_task_inputs": resolve_workflow_task_inputs, diff --git a/functional_tests/test_workflow_publication_completion.py b/functional_tests/test_workflow_publication_completion.py new file mode 100644 index 000000000..6337b7017 --- /dev/null +++ b/functional_tests/test_workflow_publication_completion.py @@ -0,0 +1,480 @@ +# test_workflow_publication_completion.py +""" +Functional tests for explicit workflow publication completion. +Version: 0.261.118 +Implemented in: 0.261.118 + +Production normalization, receipt decisions and native lifecycle observations use +closed fictional storage/processing boundaries. Native Analyze-to-runner coverage +is in test_workflow_structured_publication.py; no live files are published. +""" + +from copy import deepcopy +import hashlib +import sys + +from flask import Flask, jsonify +import pytest + +from test_analysis_artifact_publication import load_functions, normalizers, publication, publish # noqa: F401 +from test_workflow_structured_flow import definition +from functions_workflow_definitions import ( + WorkflowDefinitionError, validate_workflow_publication_completion, workflow_definition_revision, +) +from functions_workflow_flow import compile_workflow_flow +from functions_workflow_readiness import validate_publication_reference, workflow_outputs_ready, WorkflowOutputUnavailable +from functions_artifact_publication_readiness import ( + PUBLICATION_BINDING, PUBLICATION_PROCESSING, begin_publication_processing, + finish_publication_processing, inspect_publication_readiness, public_publication_status, +) +from content_screening.access import public_document_payload, reject_screening_fields +from content_screening.contracts import DocumentHeldError, ScreeningValidationError + + +SOURCE_RECEIPT = { + "producer": {"workflow_id": "workflow", "run_id": "run", "task_id": "analyze", + "node_id": "analyze", "execution_id": "a" * 64, "attempt": 1, "iteration_path": []}, + "output_name": "records", "result_ref": {"sha256": "b" * 64}, + "output_ref": {"sha256": "c" * 64}, "analysis_result": True, +} + + +def submit(fixture, policy="submitted", scope="personal", **kwargs): + return publish(fixture, scope, completion_policy=policy, source_receipt=deepcopy(SOURCE_RECEIPT), **kwargs) + + +def receipt(fixture, result): + return deepcopy(fixture.messages.records["artifact-1"]["metadata"][ + fixture.module.RECEIPTS_FIELD + ][result["publication"]["id"]]) + + +def document(fixture, result): + return deepcopy(fixture.destinations[result["workspace_scope"]].records[result["document"]["id"]]) + + +def request(fixture, result): + saved = receipt(fixture, result) + return { + "publication": {"artifact_format": "md", **saved["destination"], "completion_policy": saved["completion_policy"]}, + "artifact_reference": {**saved["artifact_reference"], "producer": fixture.artifact["metadata"]["analysis_producer"]}, + "request_id": saved["request_id"], "receipt_id": saved["id"], "source_receipt": saved["source_receipt"], + } + + +def observe(fixture, result, *, reconcile=False): + return fixture.module.read_workflow_artifact_publication( + "actor", request(fixture, result), reconcile=reconcile, + execution_check=lambda: None, + ) + + +@pytest.mark.parametrize("policy", ["submitted", "approved", "indexed_ready"]) +def test_additive_policy_is_strict_and_changes_authored_revision(policy): + normalize = normalizers()["normalize_workflow_publication"] + original = {"artifact_format": "md", "workspace_scope": "personal"} + assert normalize(original) == original + assert normalize({**original, "completion_policy": policy}) == {**original, "completion_policy": policy} + workflow = definition() + before = workflow_definition_revision(workflow) + workflow["tasks"][0]["publication"] = {**original, "completion_policy": policy} + compile_workflow_flow(workflow) + assert workflow_definition_revision(workflow) != before + for version in (1, 2): + with pytest.raises(WorkflowDefinitionError, match="version-3"): + validate_workflow_publication_completion({**workflow, "definition_version": version}) + with pytest.raises(WorkflowDefinitionError, match="version-3"): + validate_workflow_publication_completion({**workflow, "durable_execution": False}) + + +@pytest.mark.parametrize("policy", [None, "", True, 1, {}, [], "ready", "Submitted", " submitted "]) +def test_unknown_policy_never_becomes_legacy_or_success(policy): + with pytest.raises(WorkflowDefinitionError): + normalizers()["normalize_workflow_publication"]({ + "artifact_format": "md", "workspace_scope": "personal", "completion_policy": policy, + }) + + +@pytest.mark.parametrize("scope", ["personal", "group", "public"]) +@pytest.mark.parametrize("policy", ["submitted", "approved", "indexed_ready"]) +def test_levels_do_not_confuse_submission_approval_and_readiness(publication, scope, policy): + result = submit(publication, policy, scope) + status = result["publication"] + assert status["submission"] == "confirmed" + assert status["approval"] == ("not_required" if scope == "personal" else "pending") + assert status["policy_satisfied"] is (policy == "submitted" or scope == "personal" and policy == "approved") + assert status["index"] != "ready" + expected = policy if status["policy_satisfied"] else "waiting_processing" if scope == "personal" else "waiting_approval" + assert status["state"] == expected + assert submit(publication, policy, scope)["publication"] == status + assert len(publication.calls["create"]) == 1 + assert len(publication.calls["queue"]) == (1 if scope == "personal" else 0) + assert len(publication.calls["notify"]) == (0 if scope == "personal" else 2) + + +def test_native_completion_and_full_exact_index_count_are_both_required(publication, tmp_path): + result = submit(publication, "indexed_ready") + target, saved = document(publication, result), receipt(publication, result) + target["status"] = "Processing complete" + target["percentage_complete"] = 100 + assert inspect_publication_readiness(saved, target)["processing"] == "not_started" + source = tmp_path / "artifact.md" + source.write_bytes(publication.state["content"]) + assert begin_publication_processing(target, source) + running = document(publication, result) + assert inspect_publication_readiness(saved, running)["processing"] == "running" + with pytest.raises(RuntimeError, match="already"): + begin_publication_processing(running, source) + finish_publication_processing(running, indexed_chunks=3) + complete = document(publication, result) + reader = lambda *args, **kwargs: document(publication, result) + partial = inspect_publication_readiness(saved, complete, available_reader=reader, index_count=lambda *args: 2) + assert partial["processing"] == "complete" and partial["index"] == "pending" + assert inspect_publication_readiness( + saved, complete, available_reader=reader, index_count=lambda *args: 3, + )["index"] == "ready" + assert begin_publication_processing(complete, source) is False + assert len(publication.calls["queue"]) == 1 + + +def test_changed_native_input_is_rejected_before_indexing(publication, tmp_path): + result = submit(publication, "indexed_ready") + source = tmp_path / "changed.md" + source.write_bytes(b"not the original artifact") + with pytest.raises(ValueError, match="original artifact"): + begin_publication_processing(document(publication, result), source) + current = document(publication, result) + status = inspect_publication_readiness(receipt(publication, result), current) + assert status["processing"] == "failed" and status["reason_code"] == "publication_content_changed" + + +@pytest.mark.parametrize("state", [None, "future-state", 1]) +def test_unknown_screening_state_is_explicitly_unavailable(publication, state): + result = submit(publication, "indexed_ready") + target = document(publication, result) + target["content_screening"] = {"state": state} + assert inspect_publication_readiness(receipt(publication, result), target)["reason_code"] == "publication_screening_unavailable" + + +@pytest.mark.parametrize("chunks", [0, None, True]) +def test_empty_or_unproven_index_projection_is_not_ready(publication, chunks): + result = submit(publication, "indexed_ready") + target, saved = document(publication, result), receipt(publication, result) + target[PUBLICATION_PROCESSING] = {"binding": target[PUBLICATION_BINDING], "state": "complete", "indexed_chunks": chunks} + status = inspect_publication_readiness(saved, target, index_count=lambda *args: pytest.fail("No unproven Search read")) + assert status["reason_code"] == "publication_no_indexed_content" and status["index"] == "unavailable" + + +@pytest.mark.parametrize("state,expected", [ + ("pending_scan", "pending"), ("publishing", "pending"), ("pending_review", "held"), + ("scan_error", "held"), ("rejected", "rejected"), +]) +def test_screening_holds_are_never_index_readiness(publication, state, expected): + result = submit(publication, "indexed_ready") + target = document(publication, result) + target["content_screening"] = {"state": state} + status = inspect_publication_readiness( + receipt(publication, result), target, index_count=lambda *args: pytest.fail("Held content cannot qualify"), + ) + assert status["screening"] == expected and status["index"] != "ready" + + +@pytest.mark.parametrize("state", ["cleared", "approved_with_flags"]) +def test_screened_readiness_requires_unchanged_bytes_and_current_release(publication, state): + result = submit(publication, "indexed_ready") + target, saved = document(publication, result), receipt(publication, result) + target.update(num_chunks=2, content_screening={ + "state": state, "source_revision": "1", "sanitized": False, + "active_blob": {"content_hash": saved["content_sha256"]}, + }) + read = lambda *args, **kwargs: deepcopy(target) + assert inspect_publication_readiness(saved, target, available_reader=read, index_count=lambda *args: 2)["index"] == "ready" + target["content_screening"]["sanitized"] = True + assert inspect_publication_readiness(saved, target)["reason_code"] == "publication_content_changed" + target["content_screening"]["sanitized"] = False + target["content_screening"]["active_blob"]["content_hash"] = "altered" + assert inspect_publication_readiness(saved, target)["reason_code"] == "publication_content_changed" + + +@pytest.mark.parametrize("field,value", [("receipt_id", "wrong"), ("content_sha256", "wrong"), ("document_version", 2)]) +def test_wrong_native_binding_cannot_satisfy_readiness(publication, field, value): + result = submit(publication, "indexed_ready") + target = document(publication, result) + target[PUBLICATION_BINDING][field] = value + assert inspect_publication_readiness(receipt(publication, result), target)["reason_code"] == "publication_revision_changed" + + +def test_changed_bytes_cannot_retarget_a_started_request(publication): + first = submit(publication) + artifact = deepcopy(publication.messages.records["artifact-1"]) + publication.state["content"] = b"changed bytes" + artifact["metadata"]["generated_artifact_content_sha256"] = hashlib.sha256(publication.state["content"]).hexdigest() + publication.messages.put(artifact) + with pytest.raises(ValueError, match="different artifact bytes"): + submit(publication) + assert len(publication.calls["create"]) == 1 + assert first["document"]["id"] in publication.destinations["personal"].records + + +@pytest.mark.parametrize("failure", ["create_after", "prepare_after", "notification_after"]) +def test_lost_submission_ack_is_reconciled_without_duplicate_documents_or_notices(publication, failure): + publication.state["failure"] = failure + first = submit(publication, "submitted", "group") + second = submit(publication, "submitted", "group") + assert first["publication"]["policy_satisfied"] and second["publication"]["policy_satisfied"] + assert len(publication.calls["create"]) == 1 and len(publication.calls["notify"]) == 2 + + +def test_unknown_queue_ack_needs_actual_native_evidence_not_a_status_string(publication, tmp_path): + publication.state["failure"] = "queue_after" + result = submit(publication) + target = document(publication, result) + target["status"] = "Content screening pending" + publication.destinations["personal"].put(target) + for _ in range(3): + assert observe(publication, result, reconcile=True)["publication"]["state"] == "uncertain" + source = tmp_path / "artifact.md" + source.write_bytes(publication.state["content"]) + begin_publication_processing(target, source) + assert observe(publication, result, reconcile=True)["publication"]["state"] == "submitted" + assert len(publication.calls["queue"]) == 1 + + +@pytest.mark.parametrize("scope", ["group", "public"]) +def test_receipt_approval_is_durable_and_does_not_imply_indexed(publication, scope): + result = submit(publication, "approved", scope) + publication.state["group_role"] = "DocumentManager" + publication.module.decide_artifact_publication("reviewer", document(publication, result), "approved") + publication.module.decide_artifact_publication("reviewer", document(publication, result), "approved") + status = observe(publication, result)["publication"] + assert status["policy_satisfied"] and status["approval"] == "approved" + assert status["index"] == "pending" and status["processing"] != "complete" + assert len(publication.calls["queue"]) == 1 + assert len(publication.calls["notify"]) == 3 + with pytest.raises(ValueError, match="different"): + publication.module.decide_artifact_publication("reviewer", document(publication, result), "rejected") + + +@pytest.mark.parametrize("choice", ["rejected", "cancelled"]) +def test_negative_decision_survives_destination_deletion(publication, monkeypatch, choice): + result = submit(publication, "indexed_ready", "group") + publication.state["group_role"] = "DocumentManager" + def delete(**kwargs): + assert kwargs["delete_mode"] == "current_only" + del publication.destinations["group"].records[kwargs["document_id"]] + monkeypatch.setattr(sys.modules["functions_documents"], "delete_document_revision", delete, raising=False) + publication.module.decide_artifact_publication("actor", document(publication, result), choice) + status = observe(publication, result)["publication"] + assert status["state"] == choice and not status["policy_satisfied"] + assert submit(publication, "indexed_ready", "group")["publication"]["state"] == choice + assert len(publication.calls["create"]) == 1 and not publication.calls["queue"] + + +def test_current_source_and_destination_authority_is_required_to_observe(publication): + result = submit(publication, "approved", "group") + publication.state["source_allowed"] = False + with pytest.raises(PermissionError): + observe(publication, result) + publication.state["source_allowed"] = True + publication.state["group_role"] = "Removed" + with pytest.raises(PermissionError): + observe(publication, result) + assert len(publication.calls["create"]) == 1 + + +def test_personal_destination_is_not_disclosed_to_another_workflow_viewer(publication): + result = submit(publication) + with pytest.raises(PermissionError, match="private"): + publication.module.authorize_publication_status_read("another-member", result["publication"], actor_user_id="actor") + + +@pytest.mark.parametrize("policy", ["submitted", "approved"]) +def test_weaker_policy_reports_actual_screening_without_waiting_for_it(publication, monkeypatch, policy): + def queue(**kwargs): + publication.calls["queue"].append(kwargs) + target = publication.destinations["personal"].records[kwargs["document_id"]] + target["content_screening"] = {"state": "pending_scan"} + publication.destinations["personal"].put(target) + monkeypatch.setattr(publication.module, "queue_generated_document_processing", queue) + status = submit(publication, policy)["publication"] + assert status["policy_satisfied"] is True + assert status["screening"] == "pending" + assert status["state"] == policy + + +def test_public_projection_excludes_private_ledger_and_destination_fields(publication): + result = submit(publication) + status = deepcopy(result["publication"]) + status.update(blob_path="private", source_receipt=SOURCE_RECEIPT) + status["destination"]["secret"] = "private" + public = public_publication_status(status) + assert "blob_path" not in public and "source_receipt" not in public + assert "secret" not in public["destination"] + exposed_document = public_document_payload(document(publication, result)) + assert PUBLICATION_BINDING not in exposed_document and PUBLICATION_PROCESSING not in exposed_document + + +@pytest.mark.parametrize("field", [PUBLICATION_BINDING, PUBLICATION_PROCESSING]) +def test_browser_cannot_supply_native_publication_evidence(field): + with pytest.raises(ScreeningValidationError): + reject_screening_fields({"nested": {field: {"state": "complete"}}}) + + +def test_publication_poll_requeues_only_the_existing_observer(): + reference = {"kind": "artifact_publication", "version": 1, "execution_id": "a" * 64, + "attempt": 1, "request_id": "workflow-publication:v3:exact", "receipt_id": "b" * 64} + assert workflow_outputs_ready({}, [reference], get_status=lambda *args: pytest.fail("Do not poll a tabular run")) + for changed in ({**reference, "attempt": True}, {**reference, "version": 2}, {**reference, "blob_path": "private"}): + with pytest.raises(WorkflowOutputUnavailable): + validate_publication_reference(changed) + + +@pytest.mark.parametrize("stage,scope,effect,count", [ + ("create", "personal", "create", 0), + ("prepare", "personal", "update", 0), + ("queue", "personal", "queue", 0), + ("workspace_notification", "group", "notify", 0), + ("submitter_notification", "group", "notify", 1), +]) +def test_ownership_loss_during_stage_claim_prevents_the_external_effect( + publication, monkeypatch, stage, scope, effect, count, +): + lost = {"value": False} + original = publication.module._stage + def claim(*args, **kwargs): + acquired = original(*args, **kwargs) + if args[2] == stage and not kwargs.get("complete"): + lost["value"] = True + return acquired + def owned(): + if lost["value"]: + raise RuntimeError("The workflow lost ownership.") + monkeypatch.setattr(publication.module, "_stage", claim) + with pytest.raises(RuntimeError, match="ownership"): + submit(publication, "submitted", scope, execution_check=owned) + assert len(publication.calls[effect]) == count + + +@pytest.mark.parametrize("scope", ["group", "public"]) +@pytest.mark.parametrize("action,choice", [("approve", "approved"), ("deny", "rejected"), ("cancel", "cancelled")]) +def test_existing_destination_routes_use_the_durable_receipt(publication, monkeypatch, scope, action, choice): + result = submit(publication, "indexed_ready", scope) + publication.state["group_role"] = "DocumentManager" + actor = "actor" if action == "cancel" else "reviewer" + target_id = result["document"]["id"] + scope_id = f"fixed-{scope}" + container = publication.destinations[scope] + def delete(**kwargs): + assert kwargs["document_id"] == target_id and kwargs["delete_mode"] == "current_only" + del container.records[target_id] + monkeypatch.setattr(sys.modules["functions_documents"], "delete_document_revision", delete, raising=False) + def metadata(**kwargs): + assert kwargs["document_id"] == target_id + assert kwargs["group_id" if scope == "group" else "public_workspace_id"] == scope_id + return deepcopy(container.records[target_id]) + def active_group(user, **kwargs): + publication.module.assert_group_role(user, scope_id, **kwargs) + return scope_id + route_name = f"api_{action}_{scope}_generated_artifact" + helpers = load_functions(f"route_backend_{scope}_documents.py", {route_name}, { + "jsonify": jsonify, "get_current_user_id": lambda: actor, + "require_active_group": active_group, + "require_active_public_workspace": lambda user: (scope_id, {"id": scope_id, "name": "Target"}, "DocumentManager"), + "find_group_by_id": lambda **kwargs: {"id": scope_id, "name": "Target"}, + "check_group_status_allows_operation": lambda *args: (True, ""), + "check_public_workspace_status_allows_operation": lambda *args: (True, ""), + "assert_group_role": publication.module.assert_group_role, + "get_document_metadata": metadata, + "decide_artifact_publication": publication.module.decide_artifact_publication, + "_cleanup_group_generated_artifact_notifications": lambda *args: None, + "_cleanup_public_generated_artifact_notifications": lambda *args: None, + "invalidate_group_search_cache": lambda *args: None, + "invalidate_public_workspace_search_cache": lambda *args: None, + }) + app = Flask(__name__) + app.add_url_rule("/decision", view_func=lambda: helpers[route_name](target_id), methods=["POST"]) + response = app.test_client().post("/decision", json={"group_id": "not-the-selected-destination"}) + assert response.status_code == 200, response.get_json() + assert receipt(publication, result)["decision"]["choice"] == choice + assert len(publication.calls["create"]) == 1 + assert len(publication.calls["queue"]) == (1 if choice == "approved" else 0) + + +def test_approval_rechecks_source_authority_after_byte_read(publication, monkeypatch): + result = submit(publication, "approved", "group") + publication.state["group_role"] = "DocumentManager" + def revoked(*args): + publication.state["source_allowed"] = False + return publication.state["content"] + monkeypatch.setattr(publication.module, "download_blob_content", revoked) + with pytest.raises(PermissionError): + publication.module.decide_artifact_publication("reviewer", document(publication, result), "approved") + assert "decision" not in receipt(publication, result) + assert not publication.calls["queue"] + + +def test_lost_approval_queue_ack_does_not_dispatch_a_second_job(publication, tmp_path): + result = submit(publication, "indexed_ready", "group") + publication.state["group_role"] = "DocumentManager" + publication.state["failure"] = "queue_after" + with pytest.raises(RuntimeError, match="confirmed"): + publication.module.decide_artifact_publication("reviewer", document(publication, result), "approved") + assert observe(publication, result)["publication"]["state"] == "approval_failed" + source = tmp_path / "original.md" + source.write_bytes(publication.state["content"]) + begin_publication_processing(document(publication, result), source) + for _ in range(2): + assert observe(publication, result, reconcile=True)["publication"]["state"] == "waiting_processing" + assert len(publication.calls["queue"]) == 1 + + +@pytest.mark.parametrize("scope", ["personal", "group", "public"]) +@pytest.mark.parametrize("valid_release", [False, True]) +def test_native_screening_release_reconciles_an_unacknowledged_handoff(publication, monkeypatch, scope, valid_release): + if scope == "personal": + publication.state["failure"] = "queue_after" + result = submit(publication, "indexed_ready", scope) + if scope != "personal": + publication.state["group_role"] = "DocumentManager" + publication.state["failure"] = "queue_after" + with pytest.raises(RuntimeError): + publication.module.decide_artifact_publication("reviewer", document(publication, result), "approved") + saved = receipt(publication, result) + target = document(publication, result) + assert PUBLICATION_PROCESSING not in target + target.update(num_chunks=2, content_screening={ + "state": "cleared", "source_revision": str(saved["document_version"]), + "active_blob": {"content_hash": saved["content_sha256"]}, "sanitized": False, + }) + publication.destinations[scope].put(target) + def available(*args, **kwargs): + if not valid_release: + raise DocumentHeldError() + return document(publication, result) + monkeypatch.setattr("functions_artifact_publication_readiness.assert_document_available", available) + monkeypatch.setattr("functions_artifact_publication_readiness._index_count", lambda *args: 2) + status = observe(publication, result, reconcile=True)["publication"] + assert status["policy_satisfied"] is valid_release + if valid_release: + assert status["state"] == "indexed_ready" and status["unresolved_stages"] == [] + assert submit(publication, "indexed_ready", scope)["publication"]["policy_satisfied"] + assert len(publication.calls["queue"]) == len(publication.calls["create"]) == 1 + + +@pytest.mark.parametrize("read_number", [1, 2]) +@pytest.mark.parametrize("field,value", [("is_current_version", False), ("search_visibility_state", "archived")]) +def test_readiness_rejects_archival_on_either_fresh_read(publication, read_number, field, value): + result = submit(publication, "indexed_ready") + target = document(publication, result) + target[PUBLICATION_PROCESSING] = {"binding": target[PUBLICATION_BINDING], "state": "complete", "indexed_chunks": 1} + reads = {"count": 0} + def read(*args, **kwargs): + reads["count"] += 1 + current = deepcopy(target) + if reads["count"] >= read_number: + current[field] = value + return current + status = inspect_publication_readiness( + receipt(publication, result), target, available_reader=read, index_count=lambda *args: 1, + ) + assert status["reason_code"] == "publication_revision_changed" and status["index"] != "ready" diff --git a/functional_tests/test_workflow_structured_publication.py b/functional_tests/test_workflow_structured_publication.py index e910dedb2..4f8b1b81c 100644 --- a/functional_tests/test_workflow_structured_publication.py +++ b/functional_tests/test_workflow_structured_publication.py @@ -1,8 +1,9 @@ # test_workflow_structured_publication.py """ Native Analyze and publication service integration for structured workflows. -Version: 0.261.116 +Version: 0.261.118 Implemented in: 0.261.116 +Publication completion implemented in: 0.261.118 Actual final-checkpoint adaptation, result persistence, source-authorized joins, task dispatch and publication receipts operate over fictional service doubles. @@ -23,13 +24,24 @@ from test_workflow_task_result_handoff import build_inventory_run from functions_analysis_access import AnalysisResultUnavailable from functions_workflow_execution import workflow_execution_scope +from functions_workflow_execution import WorkflowSuspended from functions_workflow_runtime_store import WorkflowRuntimeLease from functions_workflow_structured_execution import StructuredWorkflowExecution from functions_workflow_results import authorize_workflow_task_result_read, persist_workflow_task_result +from functions_workflow_result_store import WorkflowResultStore +from test_workflow_result_store import FakeBlobService +from functions_artifact_publication_readiness import begin_publication_processing, finish_publication_processing +from functions_workflow_readiness import workflow_outputs_ready -def test_actual_native_result_publishes_once_through_an_explicit_join(native_run, publication, monkeypatch): +@pytest.fixture +def publication_flow(native_run, publication, monkeypatch, request): + options = getattr(request, "param", None) or {} + if isinstance(options, str): + options = {"policy": options} workflow = definition() + if options.get("continue_on_error"): + workflow["error_handling"] = {"strategy": "continue", "retry_count": 0} workflow["tasks"][1]["output_contract"] = {"kind": "records"} workflow["tasks"][2].update( output_contract={"kind": "records"}, @@ -39,8 +51,22 @@ def test_actual_native_result_publishes_once_through_an_explicit_join(native_run publication={"artifact_format": "md", "workspace_scope": "personal"}, output_contract={"kind": "json"}, ) + if options.get("policy"): + workflow["tasks"][0]["publication"]["completion_policy"] = options["policy"] + scope = options.get("scope", "personal") + workflow["tasks"][0]["publication"].update( + workspace_scope=scope, **( + {"group_id": "fixed-group"} if scope == "group" else + {"public_workspace_id": "fixed-public"} if scope == "public" else {} + ), + ) workflow["flow"]["nodes"][1]["join"]["exports"][0]["expected_kind"] = "records" workflow, store, container, _ = create_structured_runtime(workflow, monkeypatch) + if options.get("storage") == "blob": + blobs = FakeBlobService() + configured = lambda *args, **kwargs: WorkflowResultStore(container, blobs, "private-chat-results") + monkeypatch.setattr("functions_workflow_result_store._configured_store", configured) + monkeypatch.setattr("functions_workflow_result_store._configured_result_store", configured) native_run.source["scope_id"] = "owner" native_run.run["user_id"] = "owner" monkeypatch.setitem(sys.modules, "functions_saved_analysis", saved) @@ -113,9 +139,21 @@ def execute(): with workflow_execution_scope(controller): return runner["_execute_workflow_task_sequence"](workflow, {}, "conversation-1", "run", None, {}, actor_user_id="owner") + return SimpleNamespace( + execute=execute, store=store, workflow=workflow, container=container, calls=calls, + native_run=native_run, publication=publication, options=options, + ) + + +@pytest.mark.parametrize("publication_flow", [None, "submitted", "approved"], indirect=True) +def test_actual_native_result_publishes_once_through_an_explicit_join(publication_flow): + fixture = publication_flow + execute, calls, native_run, publication, workflow = ( + fixture.execute, fixture.calls, fixture.native_run, fixture.publication, fixture.workflow, + ) result = execute() assert result["workflow_outcome"] == {"status": "completed", "success": True} - assert result["publication"]["state"] == "queued" + assert result["publication"]["state"] == fixture.options.get("policy", "queued") assert calls == ["classify", "yes"] assert native_run.reads == ["batch-1", "batch-2", "batch-3"] assert len(publication.calls["create"]) == len(publication.calls["queue"]) == 1 @@ -139,3 +177,134 @@ def execute(): }, request_id="forbidden-new-copy", ) assert publication.calls == before + + +@pytest.mark.parametrize("publication_flow", [ + {"policy": "indexed_ready", "scope": scope, "storage": storage} + for scope in ("personal", "group", "public") for storage in ("cosmos", "blob") +], indirect=True) +def test_native_publication_restarts_and_waits_for_exact_readiness(publication_flow, monkeypatch, tmp_path): + fixture, publication = publication_flow, publication_flow.publication + with pytest.raises(WorkflowSuspended): + fixture.execute() + first_control = fixture.store.read() + assert first_control["state"] == "waiting_output" and first_control["lease"] is None + first_gate = first_control["gate"] + assert first_gate["execution_id"] and first_gate["attempt"] == 1 and first_gate["input_digest"] + assert first_gate["iteration_path"] == [] + assert first_gate["publication"]["policy_satisfied"] is False + assert first_gate["choices"] == [] + assert fixture.calls == ["classify", "yes"] + assert len(publication.calls["create"]) == 1 + scope = fixture.options["scope"] + container = publication.destinations[scope] + target = copy.deepcopy(next(iter(container.records.values()))) + if scope != "personal": + assert first_gate["publication"]["state"] == "waiting_approval" + publication.state["group_role"] = "DocumentManager" + publication.module.decide_artifact_publication("reviewer", target, "approved") + target = copy.deepcopy(container.records[target["id"]]) + source = tmp_path / "actual-native-artifact.md" + source.write_bytes(publication.state["content"]) + begin_publication_processing(target, source) + finish_publication_processing(target, indexed_chunks=2) + index = {"count": 1} + monkeypatch.setattr("functions_artifact_publication_readiness._index_count", lambda *args: index["count"]) + + def continue_run(): + control = fixture.store.read() + assert workflow_outputs_ready(fixture.workflow, control["gate"]["references"]) + fixture.store.requeue_output(expected_version=control["version"], gate_id=control["gate"]["id"]) + return fixture.execute() + + for _ in range(2): + with pytest.raises(WorkflowSuspended): + continue_run() + control = fixture.store.read() + assert control["gate"]["publication"]["state"] == "waiting_index" + assert control["gate"]["attempt"] == 1 + assert control["admitted_count"] == first_control["admitted_count"] + assert control["deadline_at"] == first_control["deadline_at"] + index["count"] = 2 + completed = continue_run() + assert completed["workflow_outcome"] == {"status": "completed", "success": True} + assert completed["publication"]["state"] == "indexed_ready" + assert completed["publication"]["policy_satisfied"] is True + assert completed["task_results"][-1]["workflow_result"]["result_ref"]["storage"] == fixture.options["storage"] + assert fixture.calls == ["classify", "yes"] + assert fixture.native_run.reads == ["batch-1", "batch-2", "batch-3"] + assert len(publication.calls["create"]) == len(publication.calls["queue"]) == 1 + assert len(publication.calls["notify"]) == (0 if scope == "personal" else 3) + + +@pytest.mark.parametrize("publication_flow", [{"policy": "indexed_ready", "continue_on_error": True}], indirect=True) +def test_unmet_policy_pauses_instead_of_continue_on_error(publication_flow): + fixture = publication_flow + fixture.publication.state["failure"] = "queue_after" + with pytest.raises(WorkflowSuspended): + fixture.execute() + control = fixture.store.read() + assert control["state"] == "paused" + assert control["gate"]["publication"]["state"] == "uncertain" + assert control["gate"]["choices"] == ["resume", "cancel"] + assert control["gate"]["publication"]["policy_satisfied"] is False + for index in range(2): + fixture.store.decide( + expected_version=control["version"], gate_id=control["gate"]["id"], choice="resume", + actor_user_id="owner", request_id=f"resume-publication-{index}", + ) + with pytest.raises(WorkflowSuspended): + fixture.execute() + control = fixture.store.read() + assert control["gate"]["attempt"] == 1 + assert fixture.calls == ["classify", "yes"] + assert len(fixture.publication.calls["queue"]) == 1 + + +@pytest.mark.parametrize("publication_flow", [ + {"policy": "submitted", "scope": scope, "storage": storage} + for scope in ("group", "public") for storage in ("cosmos", "blob") +], indirect=True) +@pytest.mark.parametrize("decision", ["approved", "rejected", "cancelled", "deleted"]) +def test_crash_after_success_preserves_the_achieved_publication_snapshot(publication_flow, monkeypatch, decision): + fixture = publication_flow + cache = StructuredWorkflowExecution.cache + interrupted = {"value": False} + class WorkerRestart(BaseException): + pass + def crash_before_cache(execution, key, value): + if key == "task-result:report" and not interrupted["value"]: + interrupted["value"] = True + raise WorkerRestart() + return cache(execution, key, value) + monkeypatch.setattr(StructuredWorkflowExecution, "cache", crash_before_cache) + with pytest.raises(WorkerRestart): + fixture.execute() + publication_row = next( + row for row in fixture.container.items.values() + if row.get("record_kind") == "execution" and row["payload"].get("task_id") == "report" + )["payload"] + assert publication_row["state"] == "succeeded" + committed = copy.deepcopy(publication_row["workflow_result"]) + assert committed["publication"]["state"] == "submitted" + assert committed["publication"]["approval"] == "pending" + publication = fixture.publication + publication.state["group_role"] = "DocumentManager" + destination = publication.destinations[fixture.options["scope"]] + target = copy.deepcopy(next(iter(destination.records.values()))) + def delete(**kwargs): + assert kwargs["document_id"] == target["id"] and kwargs["delete_mode"] == "current_only" + del destination.records[target["id"]] + monkeypatch.setattr(sys.modules["functions_documents"], "delete_document_revision", delete, raising=False) + if decision == "deleted": + del destination.records[target["id"]] + else: + publication.module.decide_artifact_publication("owner" if decision == "cancelled" else "reviewer", target, decision) + for _ in range(2): + recovered = fixture.execute() + assert recovered["workflow_outcome"] == {"status": "completed", "success": True} + assert recovered["task_results"][-1]["workflow_result"] == committed + assert recovered["publication"]["approval"] == "pending" + assert fixture.calls == ["classify", "yes"] + assert len(publication.calls["create"]) == 1 + assert len(publication.calls["queue"]) == (1 if decision == "approved" else 0) diff --git a/ui_tests/fixtures/workflow_publication_completion.py b/ui_tests/fixtures/workflow_publication_completion.py new file mode 100644 index 000000000..693fbe2a5 --- /dev/null +++ b/ui_tests/fixtures/workflow_publication_completion.py @@ -0,0 +1,250 @@ +# workflow_publication_completion.py +""" +Closed publication-completion API fixtures for the real V2 SPA. +Version: 0.261.118 +Implemented in: 0.261.118 + +Reuse production definition validation and the existing scoped history fixture. +Only fictional receipt/document identities and serialized public status cross the +API boundary. No model, publication endpoint, or live document is contacted. +""" + +import copy +import hashlib +import json + +import pytest + +from ui_tests.fixtures.workflow_control_definitions import flow_binding +from ui_tests.fixtures.workflow_control_runtime import ( + WorkflowControlRuntimeFixture, + execution_id, +) +from ui_tests.fixtures.workflow_editor import ( + GROUP_ID, + connect_options, # noqa: F401 + editor_options, + workflow_record, +) + + +PUBLICATION_WORKFLOW_ID = "publication-completion" +PUBLICATION_RUN_ID = "publication-run" +GROUP_PUBLICATION_WORKFLOW_ID = "group-publication-completion" +GROUP_PUBLICATION_RUN_ID = "group-publication-run" + + +def publication_key(scope="user"): + return ( + (scope, GROUP_PUBLICATION_WORKFLOW_ID, GROUP_PUBLICATION_RUN_ID) + if scope == "group" else (scope, PUBLICATION_WORKFLOW_ID, PUBLICATION_RUN_ID) + ) + + +def digest(value): + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def publication_status(scope="personal", **overrides): + status = { + "version": 1, + "id": digest(f"fixture-receipt:{scope}"), + "document_id": f"published-{scope}-document", + "document_version": 1, + "destination": { + "workspace_scope": scope, + **({"group_id": GROUP_ID} if scope == "group" else {}), + **({"public_workspace_id": "public-handbook"} if scope == "public" else {}), + }, + "completion_policy": "indexed_ready", + "policy_satisfied": False, + "state": "waiting_approval" if scope != "personal" else "waiting_processing", + "submission": "confirmed", + "approval": "pending" if scope != "personal" else "not_required", + "processing": "not_started" if scope != "personal" else "queued", + "screening": "not_required", + "index": "pending", + "reason_code": "publication_waiting_approval" if scope != "personal" else "publication_waiting_processing", + "retryable": False, + "unresolved_stages": ["approval"] if scope != "personal" else ["processing"], + } + status.update(copy.deepcopy(overrides)) + return status + + +def publication_workflow_record(scope="user"): + _, workflow_id, _ = publication_key(scope) + shared = scope == "group" + tasks = [ + { + "id": task_id, "type": "instructions", "name": name, + "instructions": instructions, "order": index + 1, + "runner": {"type": "inherit"}, "reference_ids": [], "inputs": [], + "document_action": {"type": "none"}, + "output_contract": {"kind": "json", "allow_partial": False, "require_complete_coverage": False}, + } + for index, (task_id, name, instructions) in enumerate([ + ("analyze", "Analyze source", "Analyze the selected source and save its native artifact."), + ("publish", "Publish artifact", "Publish the existing analysis artifact without invoking a model."), + ]) + ] + tasks[0]["document_action"] = { + "type": "analyze", "doc_scope": "group" if shared else "personal", + "document_ids": ["group-brief" if shared else "personal-brief"], + **({"active_group_ids": [GROUP_ID]} if shared else {}), + } + tasks[1]["inputs"] = [flow_binding("analysis", "analyze", "authoritative", kind="any")] + tasks[1]["publication"] = { + "artifact_format": "md", "workspace_scope": "group" if shared else "personal", + "completion_policy": "indexed_ready", + **({"group_id": GROUP_ID} if shared else {}), + } + return workflow_record( + workflow_id, name="Group publication workflow" if shared else "Publication workflow", + definition_version=3, durable_execution=True, tasks=tasks, + flow={ + "id": "root", + "nodes": [{"id": task["id"], "kind": "task", "task_id": task["id"]} for task in tasks], + "outputs": [flow_binding("publication", "publish")], + }, + limits={"max_executions": 5000, "deadline_seconds": 86400}, + **({"group_id": GROUP_ID} if shared else {}), + ) + + +class WorkflowPublicationFixture(WorkflowControlRuntimeFixture): + """Publication authoring plus exact run/attempt pages on a closed boundary.""" + + def __init__(self, page): + super().__init__(page) + self.publication_policies = ["submitted", "approved", "indexed_ready"] + self.stale_publication_decision = False + self.publication_outputs = {} + self.personal_workflows = {PUBLICATION_WORKFLOW_ID: publication_workflow_record()} + self.group_workflows[GROUP_ID] = {GROUP_PUBLICATION_WORKFLOW_ID: publication_workflow_record("group")} + for scope in ("user", "group"): + self.set_publication_status(publication_status("group" if scope == "group" else "personal"), scope=scope) + + def set_publication_status(self, status, *, scope="user", attempt=1): + key = publication_key(scope) + _, workflow_id, run_id = key + eid = execution_id(workflow_id, run_id, "publish") + state = "completed" if status["policy_satisfied"] else ( + "waiting_output" if status["state"].startswith("waiting_") else "paused" + ) + runtime = { + "schema_version": 2, "version": self.workflow_runtimes.get(key, {}).get("version", 4) + 1, "state": state, + "phase": "Publication checkpoint", "progress": {"completed": 1, "total": 2}, + "memory": {"unit_count": 2, "decision_count": 0}, + } + if state != "completed": + runtime["gate"] = { + "id": digest(f"{eid}:gate:{attempt}"), + "kind": "output" if state == "waiting_output" else "pause", + "unit_id": "task:publish", "execution_id": eid, "node_id": "publish", + "attempt": attempt, "iteration_path": [], "input_digest": digest(f"{eid}:input"), + "choices": [] if state == "waiting_output" else ( + ["resume", "cancel"] if status["retryable"] else ["cancel"] + ), + "reason": "The requested publication completion level has not been met.", + "publication": copy.deepcopy(status), + } + self.workflow_runtimes[key] = runtime + self.runtime_can_decide[key] = True + self.workflow_runs[workflow_id] = [{ + "id": run_id, "workflow_id": workflow_id, "definition_version": 3, + "status": state, "durable_execution": True, + "started_at": "2026-09-18T12:00:00Z", + "completed_at": "2026-09-18T12:05:00Z" if state == "completed" else None, + }] + workflow = self.group_workflows[GROUP_ID][workflow_id] if scope == "group" else self.personal_workflows[workflow_id] + workflow["status"] = state + workflow["active_run_id"] = run_id if state != "completed" else None + workflow["tasks"][1]["publication"] = { + "artifact_format": "md", **copy.deepcopy(status["destination"]), + "completion_policy": status["completion_policy"], + } + result = {"publication": copy.deepcopy(status)} + if state == "completed": + identity = { + "workflow_id": workflow_id, "run_id": run_id, "node_id": "publish", + "task_id": "publish", "execution_id": eid, "iteration_path": [], "attempt": attempt, + } + content = json.dumps({ + "contract_version": "workflow-result-v2", "producer": identity, + "output_name": "json", "kind": "json", "value": {"publication": status}, + }, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + self.publication_outputs[(*key, eid, attempt)] = content + result.update( + authoritative_output="json", outputs={"json": {"kind": "json"}}, + result_ref={ + "storage": "cosmos", "schema_version": 1, + "sha256": digest(content), "size_bytes": len(content), "chunk_count": 1, + }, + ) + record = { + "execution_id": eid, "node_id": "publish", "node_kind": "task", "task_id": "publish", + "iteration_path": [], "region_id": "root", "sequence": 2, "state": state, + "attempt": attempt, "workflow_result": result, + } + self.execution_pages[key] = {"": {"items": [copy.deepcopy(record)], "next_cursor": None}} + self.attempt_pages[(*key, eid)] = {"": {"items": [copy.deepcopy(record)], "next_cursor": None}} + self.execution_nodes[(*key, eid)] = "publish" + self.decision_pages[key] = {"": {"items": [], "next_cursor": None}} + + def _dispatch(self, route, entry): + if entry.path in {"/api/user/workflows/editor-options", "/api/group/workflows/editor-options"}: + assert entry.method == "GET", entry + shared = entry.path.startswith("/api/group/") + assert entry.query.get("group_id") == ([GROUP_ID] if shared else None), entry + options = editor_options("group" if shared else "personal", GROUP_ID if shared else None) + if self.publication_policies is not None: + options["supported_publication_completion_policies"] = copy.deepcopy(self.publication_policies) + self._json(route, options) + else: + super()._dispatch(route, entry) + + def _execution_resource(self, route, entry): + if not entry.path.endswith("/result"): + super()._execution_resource(route, entry) + return + key = self._key(entry) + parts = entry.path.split("/") + eid, attempt = parts[8], int(parts[10]) + assert (*key, eid, attempt) in self.publication_outputs, entry + assert entry.query.get("output") == ["authoritative"], entry + content = self.publication_outputs[(*key, eid, attempt)] + offset = int(entry.query.get("offset", ["0"])[0]) + limit = int(entry.query.get("limit", ["2000"])[0]) + assert 0 <= offset < len(content) and 1 <= limit <= 2000, entry + end = min(len(content), offset + limit) + self._json(route, { + "content": content[offset:end], "output_name": "json", "offset": offset, + "next_offset": end if end < len(content) else None, + "total_bytes": len(content), "complete": end == len(content), "sha256": digest(content), + }) + + def _workflow_runtime(self, route, entry): + if entry.method == "POST" and entry.path.endswith("/decision"): + parts = entry.path.split("/") + key = (parts[2], parts[4], parts[6]) + assert key == publication_key(parts[2]), entry + runtime = self.workflow_runtimes[key] + assert set(entry.body) == {"expected_version", "gate_id", "choice", "request_id"}, entry + assert entry.body["choice"] in {"resume", "cancel"}, entry + if self.stale_publication_decision: + self.stale_publication_decision = False + runtime["version"] += 1 + runtime["gate"]["id"] = digest(f"{runtime['gate']['id']}:refreshed") + if entry.body["expected_version"] != runtime["version"] or entry.body["gate_id"] != runtime["gate"]["id"]: + self._json(route, {"error": "Runtime changed. Review the current gate."}, 409) + return + assert entry.body["choice"] in runtime["gate"]["choices"], entry + super()._workflow_runtime(route, entry) + + +@pytest.fixture +def publication_ui(page): + fixture = WorkflowPublicationFixture(page) + yield fixture + fixture.assert_clean() diff --git a/ui_tests/test_v2_workflow_publication_completion.py b/ui_tests/test_v2_workflow_publication_completion.py new file mode 100644 index 000000000..adfee9100 --- /dev/null +++ b/ui_tests/test_v2_workflow_publication_completion.py @@ -0,0 +1,452 @@ +# test_v2_workflow_publication_completion.py +""" +UI coverage for publication completion authoring and exact run inspection. +Version: 0.261.118 +Implemented in: 0.261.118 + +The production SPA uses a closed API fixture with serialized public publication +status. Tests never publish a document, invoke a model, or contact a live service. +""" + +import copy +import re +import sys +from pathlib import Path + +import pytest +from playwright.sync_api import expect + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "ui_tests")) +sys.path.insert(0, str(ROOT / "ui_tests" / "fixtures")) + +# Shared fixture imports require the repository-local module paths above. +from ui_tests.fixtures.workflow_publication_completion import ( + GROUP_ID, + connect_options, # noqa: F401 + execution_id, + publication_key, + publication_status, + publication_ui, # noqa: F401 +) + + +pytestmark = pytest.mark.ui +POLICIES = ["submitted", "approved", "indexed_ready"] + + +def saved_workflow(ui, scope="user"): + workflow_id = publication_key(scope)[1] + return ui.group_workflows[GROUP_ID][workflow_id] if scope == "group" else ui.personal_workflows[workflow_id] + + +def open_editor(ui, scope="user", **viewport): + record = saved_workflow(ui, scope) + record.pop("active_run_id", None) + record.pop("status", None) + ui.workflow_runs[record["id"]] = [] + if scope == "group": + ui.open("/groups", **viewport) + ui.page.get_by_label("Group workspace", exact=True).select_option(GROUP_ID) + ui.page.get_by_role("button", name=f"Edit {record['name']}", exact=True).click() + else: + ui.open(f"/workspace/workflows?workflow_id={record['id']}", **viewport) + expect(ui.page.get_by_role("dialog", name="Edit workflow", exact=True)).to_be_visible() + return publication_fields(ui.page) + + +def publication_fields(page): + block = page.get_by_role("region", name="Publish artifact block", exact=True) + block.get_by_text("Runner, inputs, references and outputs", exact=True).click() + return block + + +def policy_field(block): + return block.get_by_label("Complete publication when for Publish artifact", exact=True) + + +def save_editor(ui): + ui.page.get_by_role("button", name="Save workflow", exact=True).click() + expect(ui.page.get_by_role("dialog", name="Edit workflow", exact=True)).to_have_count(0) + return ui.workflow_writes[-1] + + +def open_run(ui, scope="user", **viewport): + record = saved_workflow(ui, scope) + if scope == "group": + ui.open("/groups", **viewport) + ui.page.get_by_label("Group workspace", exact=True).select_option(GROUP_ID) + else: + ui.open("/workspace/workflows", **viewport) + row = ui.page.get_by_role("listitem").filter(has_text=record["name"]).first + row.get_by_role("button", name="Show run history", exact=True).click() + row.get_by_role("button", name="Show run task results", exact=True).click() + expect(ui.page.get_by_text("Workflow execution history", exact=True)).to_be_visible() + _, workflow_id, run_id = publication_key(scope) + return execution_id(workflow_id, run_id, "publish") + + +def expect_fact(region, name, value): + term = region.locator("dt").filter(has_text=re.compile(f"^{re.escape(name)}$")) + expect(term.locator("..").locator("dd")).to_have_text(value) + + +def runtime_writes(ui): + return [request for request in ui.writes if "/runtime/" in request.path] + + +@pytest.mark.parametrize("scope", ["user", "group"]) +@pytest.mark.parametrize("policy", POLICIES) +def test_authoring_reopens_policy_and_clears_only_incompatible_destination_ids(publication_ui, scope, policy): + ui, page = publication_ui, publication_ui.page + block = open_editor(ui, scope) + policy_field(block).select_option(policy) + for destination, identifier in (("group", GROUP_ID), ("public", "public-handbook"), ("personal", None)): + block.get_by_label("Publication scope for Publish artifact", exact=True).select_option(destination) + if identifier: + block.get_by_label("Publication workspace ID for Publish artifact", exact=True).fill(identifier) + expect(policy_field(block)).to_have_value(policy) + if scope == "group": + block.get_by_label("Publication scope for Publish artifact", exact=True).select_option("group") + block.get_by_label("Publication workspace ID for Publish artifact", exact=True).fill(GROUP_ID) + write = save_editor(ui) + assert write.body["tasks"][1]["publication"] == { + "artifact_format": "md", "workspace_scope": "group" if scope == "group" else "personal", + "completion_policy": policy, **({"group_id": GROUP_ID} if scope == "group" else {}), + } + assert write.body["definition_version"] == 3 and write.body["durable_execution"] is True + assert write.query.get("group_id") == ([GROUP_ID] if scope == "group" else None) + page.get_by_role("button", name=f"Edit {saved_workflow(ui, scope)['name']}", exact=True).click() + expect(policy_field(publication_fields(page))).to_have_value(policy) + assert not runtime_writes(ui) + + +@pytest.mark.parametrize("scope", ["user", "group"]) +@pytest.mark.parametrize("advertised", [True, False]) +def test_legacy_omission_survives_an_unrelated_edit(publication_ui, scope, advertised): + ui = publication_ui + record = saved_workflow(ui, scope) + record["tasks"][1]["publication"].pop("completion_policy") + original = copy.deepcopy(record["tasks"][1]["publication"]) + ui.publication_policies = POLICIES if advertised else None + block = open_editor(ui, scope) + expect(policy_field(block)).to_have_value("") + expect(policy_field(block).locator("option:checked")).to_have_text("Existing behavior (no completion policy)") + if not advertised: + expect(policy_field(block)).to_be_disabled() + block.get_by_label("Instructions", exact=True).fill("Publish the saved artifact with the existing behavior.") + assert save_editor(ui).body["tasks"][1]["publication"] == original + + +@pytest.mark.parametrize("scope", ["user", "group"]) +@pytest.mark.parametrize("capabilities", [POLICIES, None, ["approved"]]) +def test_new_publication_defaults_to_submitted_only_when_advertised(publication_ui, scope, capabilities): + ui = publication_ui + saved_workflow(ui, scope)["tasks"][1].pop("publication") + ui.publication_policies = capabilities + block = open_editor(ui, scope) + block.get_by_text("Publish an existing analysis artifact", exact=True).click() + expected = "submitted" if capabilities and "submitted" in capabilities else "" + expect(policy_field(block)).to_have_value(expected) + publication = save_editor(ui).body["tasks"][1]["publication"] + if expected: + assert publication["completion_policy"] == "submitted" + else: + assert "completion_policy" not in publication + + +def test_explicitly_returning_to_existing_behavior_removes_the_policy(publication_ui): + ui, page = publication_ui, publication_ui.page + block = open_editor(ui) + policy_field(block).select_option("") + assert "completion_policy" not in save_editor(ui).body["tasks"][1]["publication"] + page.get_by_role("button", name="Edit Publication workflow", exact=True).click() + expect(policy_field(publication_fields(page))).to_have_value("") + + +@pytest.mark.parametrize("policy,capabilities,version,durable", [ + ("indexed_ready", None, 3, True), + ("indexed_ready", ["submitted"], 3, True), + ("future_policy", POLICIES, 3, True), + (None, POLICIES, 3, True), + ("", POLICIES, 3, True), + ({"level": "approved"}, POLICIES, 3, True), + (["submitted"], POLICIES, 3, True), + ("submitted", POLICIES, 2, True), + ("submitted", POLICIES, 3, False), +]) +def test_unsupported_completion_semantics_preserve_payload_read_only(publication_ui, policy, capabilities, version, durable): + ui, page = publication_ui, publication_ui.page + record = saved_workflow(ui) + record["tasks"][1]["publication"]["completion_policy"] = copy.deepcopy(policy) + record["definition_version"] = version + record["durable_execution"] = durable + original_publication = copy.deepcopy(record["tasks"][1]["publication"]) + ui.publication_policies = capabilities + record.pop("active_run_id", None) + ui.workflow_runs[record["id"]] = [] + ui.open(f"/workspace/workflows?workflow_id={record['id']}") + expect(page.get_by_role("alert").filter(has_text="completion polic")).to_be_visible() + expect(page.get_by_role("button", name="Save workflow", exact=True)).to_have_count(0) + expect(page.get_by_label("Workflow name", exact=True)).to_be_disabled() + assert record["tasks"][1]["publication"] == original_publication + assert not ui.workflow_writes + + +@pytest.mark.parametrize("destination,changes,heading", [ + ("personal", { + "completion_policy": "submitted", "policy_satisfied": True, "state": "submitted", + "reason_code": "publication_submitted", "unresolved_stages": [], + }, "Publication submitted"), + ("personal", { + "completion_policy": "approved", "policy_satisfied": True, "state": "approved", + "reason_code": "publication_approved", "unresolved_stages": [], + }, "Publication approval requirement met"), + ("group", { + "completion_policy": "submitted", "policy_satisfied": True, "state": "submitted", + "reason_code": "publication_submitted", "unresolved_stages": [], + }, "Publication submitted"), + ("group", {}, "Waiting for destination approval"), + ("group", { + "state": "waiting_processing", "approval": "approved", "processing": "running", + "reason_code": "publication_waiting_processing", "unresolved_stages": ["processing"], + }, "Waiting for document processing"), + ("public", { + "state": "waiting_screening", "approval": "approved", "processing": "complete", "screening": "held", + "reason_code": "publication_waiting_screening", "unresolved_stages": ["screening"], + }, "Waiting for content screening"), + ("group", { + "state": "waiting_index", "approval": "approved", "processing": "complete", "screening": "available", + "reason_code": "publication_waiting_index", "unresolved_stages": ["index"], + }, "Waiting for search visibility"), + ("public", { + "policy_satisfied": True, "state": "indexed_ready", "approval": "approved", + "processing": "complete", "screening": "available", "index": "ready", + "reason_code": "publication_indexed_ready", "unresolved_stages": [], + }, "Publication indexed and ready"), +]) +def test_actual_publication_facts_do_not_imply_later_stage_completion(publication_ui, destination, changes, heading): + ui, page = publication_ui, publication_ui.page + status = publication_status(destination, **changes) + ui.set_publication_status(status) + eid = open_run(ui) + summary = page.get_by_role("region", name=f"Publication for execution {eid}", exact=True) + displayed_heading = f"Saved completion observation: {heading}" if status["policy_satisfied"] else heading + expect(summary.get_by_text(displayed_heading, exact=True)).to_be_visible() + if status["policy_satisfied"]: + expect(summary).to_contain_text("Later destination changes do not update this snapshot.") + expect(summary).to_contain_text("does not confirm current destination approval, availability, or index readiness.") + else: + expect(summary.get_by_text("Saved completion observation:", exact=False)).to_have_count(0) + labels = {"submitted": "Submitted", "approved": "Approved", "indexed_ready": "Indexed and ready"} + expect_fact(summary, "Requested completion", labels[status["completion_policy"]]) + expect_fact(summary, "Completion requirement", "Met" if status["policy_satisfied"] else "Not met") + for label, field in ( + ("Submission", "submission"), ("Destination approval", "approval"), + ("Processing", "processing"), ("Screening", "screening"), ("Index", "index"), + ): + expect_fact(summary, label, status[field].replace("_", " ").capitalize()) + expect(summary).to_contain_text(status["id"]) + expect(summary).to_contain_text(status["document_id"]) + expect(summary.get_by_role("link")).to_have_count(0) + if not status["policy_satisfied"]: + gate = page.get_by_role("region", name="Publication status", exact=True) + expect(gate.get_by_text(heading, exact=True)).to_be_visible() + expect(page.get_by_role("button", name="Cancel run", exact=True)).to_be_visible() + for name in ("Approve task", "Reject task", "Retry task"): + expect(page.get_by_role("button", name=name, exact=True)).to_have_count(0) + assert not runtime_writes(ui) + + +def test_exact_attempt_status_and_output_do_not_reuse_an_older_attempt(publication_ui): + ui, page = publication_ui, publication_ui.page + status = publication_status( + "group", state="indexed_ready", approval="approved", processing="complete", + screening="available", index="ready", policy_satisfied=True, + reason_code="publication_indexed_ready", unresolved_stages=[], + ) + ui.set_publication_status(status, scope="group", attempt=2) + scope, workflow_id, run_id = publication_key("group") + eid = execution_id(workflow_id, run_id, "publish") + attempts = ui.attempt_pages[(scope, workflow_id, run_id, eid)][""]["items"] + attempts.insert(0, { + "execution_id": eid, "node_id": "publish", "task_id": "publish", "iteration_path": [], + "attempt": 1, "state": "paused", + "workflow_result": {"publication": publication_status( + "group", state="uncertain", submission="uncertain", reason_code="publication_uncertain", + retryable=True, unresolved_stages=["submission"], + )}, + }) + open_run(ui, "group") + page.get_by_role("button", name=f"Show execution attempts for {eid}", exact=True).click() + earlier = page.get_by_role("region", name=f"Publication for execution {eid} attempt 1", exact=True) + current = page.get_by_role("region", name=f"Publication for execution {eid} attempt 2", exact=True) + expect_fact(earlier, "Submission", "Uncertain") + expect_fact(earlier, "Completion requirement", "Not met") + expect_fact(current, "Index", "Ready") + expect_fact(current, "Completion requirement", "Met") + expect(earlier.get_by_text("Saved completion observation:", exact=False)).to_have_count(0) + expect(current.get_by_text("Saved completion observation: Publication indexed and ready", exact=True)).to_be_visible() + expect(current).to_contain_text("Refresh rereads the saved observation") + page.get_by_role("button", name="Load authoritative output excerpt", exact=True).click() + expect(page.locator("pre").filter(has_text='"attempt":2')).to_be_visible() + request = next(request for request in ui.requests if request.path.endswith("/result")) + assert request.path == f"/api/group/workflows/{workflow_id}/runs/{run_id}/executions/{eid}/attempts/2/result" + assert request.query["group_id"] == [GROUP_ID] + assert not any("/tasks/" in request.path for request in ui.requests) + + +def test_stale_resume_requires_review_and_never_republishes(publication_ui): + ui, page = publication_ui, publication_ui.page + status = publication_status( + "group", state="uncertain", submission="uncertain", retryable=True, + reason_code="publication_uncertain", unresolved_stages=["submission"], + ) + ui.set_publication_status(status, scope="group") + key = publication_key("group") + original_gate = copy.deepcopy(ui.workflow_runtimes[key]["gate"]) + open_run(ui, "group", width=390, height=844, theme="dark") + ui.assert_no_overflow() + ui.stale_publication_decision = True + resume = page.get_by_role("button", name="Resume / check again", exact=True) + resume.focus() + resume.press("Enter") + expect(page.get_by_role("alert").filter(has_text="Runtime changed before your decision")).to_be_visible() + assert len(runtime_writes(ui)) == 1 + assert runtime_writes(ui)[0].body["gate_id"] == original_gate["id"] + expect(page.get_by_role("region", name="Publication status", exact=True)).to_contain_text(status["id"]) + resume.click() + expect(resume).to_have_count(0) + first, second = runtime_writes(ui) + assert second.body["expected_version"] == first.body["expected_version"] + 1 + assert second.body["gate_id"] != first.body["gate_id"] + assert second.body["request_id"] != first.body["request_id"] + assert all(request.query.get("group_id") == [GROUP_ID] for request in (first, second)) + assert all(request.path.endswith("/runtime/decision") for request in runtime_writes(ui)) + assert not any(request.path.endswith(("/run", "/publish", "/promote")) for request in ui.writes) + + +@pytest.mark.parametrize("state,retryable", [ + ("uncertain", True), ("approval_failed", True), ("processing_failed", False), + ("rejected", False), ("cancelled", False), ("content_changed", False), ("unavailable", True), +]) +def test_unmet_publication_uses_only_backend_allowed_pause_actions(publication_ui, state, retryable): + ui, page = publication_ui, publication_ui.page + status = publication_status( + "group", state=state, retryable=retryable, reason_code=f"publication_{state}", + submission="uncertain" if state == "uncertain" else "confirmed", + approval={"rejected": "rejected", "cancelled": "cancelled", "approval_failed": "failed", + "processing_failed": "approved", "content_changed": "approved"}.get(state, "pending"), + processing={"processing_failed": "failed", "content_changed": "complete"}.get(state, "not_started"), + screening="changed" if state == "content_changed" else "not_required", + index="unavailable" if state in {"unavailable", "content_changed"} else "pending", + unresolved_stages=[{ + "uncertain": "submission", "approval_failed": "approval", "rejected": "approval", + "cancelled": "approval", "processing_failed": "processing", "content_changed": "screening", + "unavailable": "index", + }[state]], + ) + ui.set_publication_status(status) + open_run(ui) + expect(page.get_by_role("region", name="Publication status", exact=True)).to_contain_text(f"Reason code: publication_{state}") + expect(page.get_by_role("button", name="Resume / check again", exact=True)).to_have_count(1 if retryable else 0) + expect(page.get_by_role("button", name="Cancel run", exact=True)).to_be_visible() + expect(page.get_by_role("button", name="Retry task", exact=True)).to_have_count(0) + if state == "content_changed": + page.get_by_role("button", name="Cancel run", exact=True).click() + expect(page.get_by_role("region", name="Publication status", exact=True)).to_have_count(0) + assert runtime_writes(ui)[0].body["choice"] == "cancel" + + +@pytest.mark.parametrize("endpoint", ["runtime", "executions", "attempts"]) +@pytest.mark.parametrize("status_code", [403, 404]) +def test_access_failure_removes_all_cached_publication_details(publication_ui, endpoint, status_code): + ui, page = publication_ui, publication_ui.page + eid = open_run(ui) + page.get_by_role("button", name=f"Show execution attempts for {eid}", exact=True).click() + expect(page.get_by_role("region", name=f"Publication for execution {eid} attempt 1", exact=True)).to_be_visible() + _, workflow_id, run_id = publication_key() + suffix = f"executions/{eid}/attempts" if endpoint == "attempts" else endpoint + path = f"/api/user/workflows/{workflow_id}/runs/{run_id}/{suffix}" + ui.failures.append(("GET", path, status_code, {"error": "Publication access is no longer available."})) + if endpoint != "runtime": + history = page.get_by_role("list", name="Workflow run history", exact=True) + history.get_by_role("button", name="Refresh", exact=True).nth(1 if endpoint == "attempts" else 0).click() + expect(page.get_by_role("alert").filter(has_text="Cached run details were removed")).to_be_visible(timeout=10000) + expect(page.get_by_role("region", name=re.compile("^Publication (status|for execution)"))).to_have_count(0) + expect(page.get_by_text("published-personal-document", exact=False)).to_have_count(0) + + +@pytest.mark.parametrize("status_code", [403, 404]) +def test_resume_access_failure_clears_the_gate_and_history(publication_ui, status_code): + ui, page = publication_ui, publication_ui.page + ui.set_publication_status(publication_status(state="uncertain", retryable=True, reason_code="publication_uncertain")) + open_run(ui) + ui.fail_next_runtime_decision(status_code) + page.get_by_role("button", name="Resume / check again", exact=True).click() + expect(page.get_by_role("alert").filter(has_text="Cached run details were removed")).to_be_visible() + expect(page.get_by_role("region", name=re.compile("^Publication (status|for execution)"))).to_have_count(0) + + +@pytest.mark.parametrize("location,field,value", [ + ("runtime", "state", "future_state"), + ("runtime", "policy_satisfied", "true"), + ("executions", "document_version", 0), + ("executions", "private_reference", {"blob": "private-publication-marker"}), + ("attempts", "completion_policy", None), + ("attempts", "reason_code", "provider error: private-publication-marker"), +]) +def test_malformed_publication_status_is_never_rendered_as_a_fact(publication_ui, location, field, value): + ui, page = publication_ui, publication_ui.page + key = publication_key() + eid = execution_id(key[1], key[2], "publish") + if location == "runtime": + status = ui.workflow_runtimes[key]["gate"]["publication"] + elif location == "executions": + status = ui.execution_pages[key][""]["items"][0]["workflow_result"]["publication"] + else: + status = ui.attempt_pages[(*key, eid)][""]["items"][0]["workflow_result"]["publication"] + status[field] = value + open_run(ui) + if location == "attempts": + page.get_by_role("button", name=f"Show execution attempts for {eid}", exact=True).click() + expect(page.get_by_role("alert").filter(has_text="unsupported")).to_be_visible() + label = "Publication status" if location == "runtime" else f"Publication for execution {eid}" + if location == "attempts": + label += " attempt 1" + expect(page.get_by_role("region", name=label, exact=True)).to_have_count(0) + expect(page.get_by_text("private-publication-marker", exact=False)).to_have_count(0) + assert not runtime_writes(ui) + + +def test_attempt_response_cannot_show_another_executions_receipt(publication_ui): + ui, page = publication_ui, publication_ui.page + key = publication_key() + eid = execution_id(key[1], key[2], "publish") + ui.attempt_pages[(*key, eid)][""]["items"][0]["execution_id"] = execution_id(key[1], key[2], "analyze") + open_run(ui) + page.get_by_role("button", name=f"Show execution attempts for {eid}", exact=True).click() + expect(page.get_by_role("alert").filter(has_text="unsupported response")).to_be_visible() + expect(page.get_by_role("region", name=f"Publication for execution {eid} attempt 1", exact=True)).to_have_count(0) + + +def test_mobile_keyboard_policy_selection_preserves_task_and_flow_identity(publication_ui): + ui, page = publication_ui, publication_ui.page + original = copy.deepcopy(saved_workflow(ui)) + block = open_editor(ui, width=390, height=844, theme="dark") + policy = policy_field(block) + policy.focus() + policy.press("ArrowUp") + expect(policy).to_have_value("approved") + expect(block.get_by_text("Personal workspace approval is not required.", exact=False)).to_be_visible() + ui.assert_no_overflow() + save = page.get_by_role("button", name="Save workflow", exact=True) + save.focus() + save.press("Enter") + expect(page.get_by_role("dialog", name="Edit workflow", exact=True)).to_have_count(0) + payload = ui.workflow_writes[-1].body + assert payload["tasks"][1]["publication"]["completion_policy"] == "approved" + assert payload["flow"] == original["flow"] + assert [task["id"] for task in payload["tasks"]] == [task["id"] for task in original["tasks"]] + ui.assert_no_overflow()