diff --git a/application/single_app/config.py b/application/single_app/config.py
index 122ab0ac4..906d30955 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.118"
+VERSION = "0.261.119"
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 c04362fcb..a90ce56b3 100644
--- a/application/single_app/content_screening/access.py
+++ b/application/single_app/content_screening/access.py
@@ -651,7 +651,11 @@ def public_history_messages(messages, user_id=None):
try:
refreshed = refresh_workspace_attachment(message, user_id)
assert_evidence_available(refreshed, user_id, cached=True)
- safe_messages.append(deepcopy(refreshed))
+ # The shared generated-file source dispatcher keeps private saved-output
+ # cards from bypassing the same boundary used by their downloads.
+ from functions_generated_artifact_sources import sanitize_generated_artifact_history
+
+ safe_messages.append(deepcopy(sanitize_generated_artifact_history(refreshed, user_id)))
except (ScreeningError, LookupError, PermissionError):
if request_context:
flask.g.content_screening_error = previous_error
diff --git a/application/single_app/functions_artifact_publication.py b/application/single_app/functions_artifact_publication.py
index e71855b12..489bdb2a1 100644
--- a/application/single_app/functions_artifact_publication.py
+++ b/application/single_app/functions_artifact_publication.py
@@ -2,6 +2,7 @@
"""Explicit workspace publication of existing artifacts, with scoped retry receipts."""
from copy import deepcopy
+from contextlib import ExitStack
from datetime import datetime, timezone
import hashlib
import json
@@ -28,6 +29,7 @@
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
+from functions_generated_artifact_sources import authorize_generated_artifact_source, has_generated_artifact_source
from functions_group import assert_group_role, check_group_status_allows_operation, find_group_by_id
from functions_notifications import create_group_notification, create_notification, create_public_workspace_notification
from functions_personal_workflows import normalize_workflow_publication
@@ -69,7 +71,9 @@ def _authorize_artifact(user_id, conversation_id, message_id):
raise LookupError("Generated artifact is unavailable.")
assert_generated_file_approval_allows_download(user_id, artifact)
assert_generated_chat_artifact_is_published_for_user(user_id, artifact)
- authorize_analysis_artifact(user_id, artifact, for_publication=True)
+ authorize_generated_artifact_source(
+ user_id, artifact, for_publication=True, native_authorizer=authorize_analysis_artifact,
+ )
return artifact
@@ -109,7 +113,7 @@ def _authorize_destination(user_id, destination):
def _artifact_identity(artifact):
metadata = artifact.get("metadata") or {}
- return {
+ identity = {
"conversation_id": artifact["conversation_id"],
"message_id": artifact["id"],
"blob_container": artifact["blob_container"],
@@ -118,6 +122,36 @@ def _artifact_identity(artifact):
"contexts": metadata.get("analysis_result_contexts"),
"content_sha256": metadata.get("generated_artifact_content_sha256"),
}
+ if has_generated_artifact_source(metadata):
+ identity["generated_source"] = deepcopy(metadata.get("generated_artifact_source"))
+ identity["source_required"] = metadata.get("generated_artifact_source_required")
+ return identity
+
+
+def _artifact_producer(artifact):
+ metadata = artifact.get("metadata") or {}
+ if has_generated_artifact_source(metadata):
+ return {"kind": "workflow_saved_output", **metadata["generated_artifact_source"]["producer"]}
+ return metadata.get("analysis_producer")
+
+
+def _publication_destination(publication):
+ return {key: value for key, value in publication.items()
+ if key not in {"artifact_format", "completion_policy", "source_kind"}}
+
+
+def _read_publication_artifact_content(artifact, resources, expected_digest, *, check=None):
+ if has_generated_artifact_source(artifact.get("metadata") or {}):
+ # The existing transport verifies into bounded private storage before any handoff.
+ from functions_simplechat_operations import open_generated_chat_artifact_stream
+
+ if (artifact.get("metadata") or {}).get("generated_artifact_content_sha256") != expected_digest:
+ raise ValueError("The generated artifact bytes changed.")
+ return resources.enter_context(open_generated_chat_artifact_stream(artifact, check=check))
+ content = download_blob_content(artifact["blob_container"], artifact["blob_path"])
+ if hashlib.sha256(content).hexdigest() != expected_digest:
+ raise ValueError("The generated artifact bytes changed.")
+ return content
def _receipt_change(artifact, key, change):
@@ -352,6 +386,19 @@ def _completion_response(receipt, document):
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,
+):
+ with ExitStack() as resources:
+ return _publish_generated_chat_artifact_for_user(
+ user_id, conversation_id=conversation_id, message_id=message_id, destination=destination,
+ request_id=request_id, requester_display_name=requester_display_name, file_name=file_name,
+ completion_policy=completion_policy, source_receipt=source_receipt,
+ execution_check=execution_check, resources=resources,
+ )
+
+
+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, resources,
):
"""Copy or request approval once; uncertain external work is never blindly repeated."""
user_id = _text(user_id, "Acting user")
@@ -377,6 +424,10 @@ def reauthorize():
_authorize_destination(user_id, destination)
metadata = artifact.get("metadata") or {}
+ bound_source = has_generated_artifact_source(metadata)
+ recheck_effect = completion_policy is not None or bound_source
+ if bound_source and source_receipt is None:
+ source_receipt = deepcopy(metadata["generated_artifact_source"]["source_receipt"])
name = str(file_name or artifact.get("filename") or "generated-artifact.json").replace("\\", "/").split("/")[-1]
output_format = str(metadata.get("generated_artifact_output_format") or "").lower()
extension = {"markdown": ".md", "md": ".md", "csv": ".csv", "json": ".json"}.get(output_format)
@@ -396,8 +447,10 @@ def reauthorize():
key = hashlib.sha256(json.dumps(identity, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
if key not in (metadata.get(RECEIPTS_FIELD) or {}):
if artifact_bytes is None:
- artifact_bytes = download_blob_content(artifact["blob_container"], artifact["blob_path"])
- if hashlib.sha256(artifact_bytes).hexdigest() != content_sha256:
+ artifact_bytes = _read_publication_artifact_content(
+ artifact, resources, content_sha256, check=execution_check,
+ )
+ if not bound_source and hashlib.sha256(artifact_bytes).hexdigest() != content_sha256:
raise ValueError("The generated artifact bytes changed.")
if destination["workspace_scope"] != "personal":
stem, suffix = os.path.splitext(name)
@@ -407,12 +460,14 @@ 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:
+ if recheck_effect:
receipt.update(
- completion_policy=completion_policy, source_receipt=deepcopy(source_receipt),
+ source_receipt=deepcopy(source_receipt),
artifact_reference={"conversation_id": conversation_id, "artifact_message_id": message_id},
source_identity=_artifact_identity(artifact),
)
+ if completion_policy is not None:
+ receipt["completion_policy"] = completion_policy
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()
@@ -427,7 +482,7 @@ def reauthorize():
if document is None:
reauthorize()
if document is None and _stage(artifact, receipt, "create"):
- if completion_policy:
+ if recheck_effect:
reauthorize()
try:
create_document(
@@ -440,7 +495,7 @@ 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 recheck_effect 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()
@@ -451,10 +506,10 @@ def reauthorize():
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:
+ if recheck_effect:
reauthorize()
updates = {"generated_artifact_publication_receipt_id": key}
- if completion_policy:
+ if recheck_effect:
updates[PUBLICATION_BINDING] = {
"version": 1, "receipt_id": key, "document_version": receipt["document_version"],
"content_sha256": content_sha256, "conversation_id": conversation_id,
@@ -487,12 +542,14 @@ def reauthorize():
if scope == "personal":
if not (receipt.get("stages") or {}).get("queue"):
if artifact_bytes is None:
- artifact_bytes = download_blob_content(artifact["blob_container"], artifact["blob_path"])
- if hashlib.sha256(artifact_bytes).hexdigest() != receipt["content_sha256"]:
+ artifact_bytes = _read_publication_artifact_content(
+ artifact, resources, receipt["content_sha256"], check=execution_check,
+ )
+ if not bound_source and hashlib.sha256(artifact_bytes).hexdigest() != receipt["content_sha256"]:
raise ValueError("The generated artifact bytes changed.")
reauthorize()
if _stage(artifact, receipt, "queue"):
- if completion_policy:
+ if recheck_effect:
reauthorize()
try:
queue_generated_document_processing(
@@ -524,13 +581,13 @@ def workspace_notice():
"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:
+ if recheck_effect:
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",
- workspace_notice, before=reauthorize if completion_policy else None,
+ workspace_notice, before=reauthorize if recheck_effect else None,
)
reauthorize()
_notify_once(
@@ -540,18 +597,18 @@ def workspace_notice():
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 {}),
+ **({"idempotency_key": f"publication:{key}:submitter"} if recheck_effect else {}),
),
- before=reauthorize if completion_policy else None,
+ before=reauthorize if recheck_effect else None,
)
if scope == "group":
invalidate_group_search_cache(destination["group_id"])
- if completion_policy:
+ if recheck_effect:
reauthorize()
return _publication_response(artifact, receipt, container)
-def publish_workflow_analysis_artifact(
+def publish_workflow_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."""
@@ -564,9 +621,19 @@ def publish_workflow_analysis_artifact(
message_id = _text(artifact_reference.get("artifact_message_id"), "Upstream artifact message id")
artifact = _authorize_artifact(user_id, conversation_id, message_id)
metadata = artifact.get("metadata") or {}
- if not metadata.get("analysis_result_required"):
+ saved_output = publication.get("source_kind") == "saved_output"
+ if saved_output:
+ if not has_generated_artifact_source(metadata):
+ raise ValueError("The upstream artifact is not bound to saved workflow records.")
+ bound_receipt = metadata["generated_artifact_source"]["source_receipt"]
+ if not isinstance(source_receipt, dict) or any(
+ source_receipt.get(key) != bound_receipt[key]
+ for key in ("producer", "result_ref", "output_name", "output_ref")
+ ):
+ raise ValueError("Publication requires the exact saved-output source receipt.")
+ elif not metadata.get("analysis_result_required") or has_generated_artifact_source(metadata):
raise ValueError("The upstream artifact is not bound to a saved final analysis.")
- if artifact_reference.get("producer") is not None and artifact_reference["producer"] != metadata.get("analysis_producer"):
+ if (saved_output or artifact_reference.get("producer") is not None) and artifact_reference.get("producer") != _artifact_producer(artifact):
raise ValueError("The upstream artifact belongs to a different analysis result.")
output_format = str(metadata.get("generated_artifact_output_format") or "").lower()
if output_format == "markdown":
@@ -574,19 +641,19 @@ def publish_workflow_analysis_artifact(
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 {}
+ producer = _artifact_producer(artifact) or {}
if (
- producer.get("kind") != "workflow" or not producer.get("execution_id")
+ producer.get("kind") != ("workflow_saved_output" if saved_output else "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.")
+ raise ValueError("Publication completion needs the exact saved 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 not in {"artifact_format", "completion_policy"}},
+ destination=_publication_destination(publication),
request_id=request_id,
completion_policy=publication.get("completion_policy"), source_receipt=source_receipt,
execution_check=execution_check,
@@ -606,6 +673,16 @@ def publish_workflow_analysis_artifact(
return response
+def publish_workflow_analysis_artifact(
+ user_id, *, publication, artifact_reference, request_id, source_receipt=None, execution_check=None,
+):
+ """Compatibility entry point; native definitions retain their original source checks."""
+ return publish_workflow_artifact(
+ user_id, publication=publication, artifact_reference=artifact_reference, request_id=request_id,
+ source_receipt=source_receipt, execution_check=execution_check,
+ )
+
+
def read_workflow_artifact_publication(
user_id, request, *, reconcile=False, execution_check=None, authorization_only=False,
):
@@ -620,10 +697,10 @@ def read_workflow_artifact_publication(
("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"}}
+ destination = _publication_destination(publication)
if (
receipt["destination"] != destination or receipt.get("source_identity") != _artifact_identity(artifact)
- or address.get("producer") != (artifact.get("metadata") or {}).get("analysis_producer")
+ or address.get("producer") != _artifact_producer(artifact)
):
raise PermissionError("The publication receipt belongs to a different producer or destination.")
_, _, container = _authorize_destination(user_id, destination)
@@ -724,6 +801,11 @@ def decide_artifact_publication(user_id, document, choice):
def _decide_artifact_publication(user_id, document, choice):
+ with ExitStack() as resources:
+ return _decide_artifact_publication_with_content(user_id, document, choice, resources=resources)
+
+
+def _decide_artifact_publication_with_content(user_id, document, choice, *, resources):
if choice not in {"approved", "rejected", "cancelled"}:
raise ValueError("Invalid publication decision.")
roles = ("Owner", "Admin", "DocumentManager", "User") if choice == "cancelled" else ("Owner", "Admin", "DocumentManager")
@@ -769,9 +851,7 @@ def authorize_decision():
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.")
+ source_bytes = _read_publication_artifact_content(artifact, resources, receipt["content_sha256"])
_authorize_artifact(receipt["actor_user_id"], artifact["conversation_id"], artifact["id"])
_authorize_destination(receipt["actor_user_id"], destination)
authorize_decision()
@@ -791,6 +871,9 @@ def record_decision(current):
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":
+ if has_generated_artifact_source(artifact.get("metadata") or {}):
+ _authorize_artifact(receipt["actor_user_id"], artifact["conversation_id"], artifact["id"])
+ _authorize_destination(receipt["actor_user_id"], destination)
authorize_decision()
_replace_publication_destination(container, receipt, {
"generated_artifact_promotion_status": "approved",
diff --git a/application/single_app/functions_generated_artifact_sources.py b/application/single_app/functions_generated_artifact_sources.py
new file mode 100644
index 000000000..b6d4b3c08
--- /dev/null
+++ b/application/single_app/functions_generated_artifact_sources.py
@@ -0,0 +1,159 @@
+# functions_generated_artifact_sources.py
+"""Shared source dispatch for generated files, independent of their renderer."""
+
+from copy import deepcopy
+import uuid
+
+from azure.core.exceptions import AzureError
+from flask import g, has_request_context
+
+from content_screening.contracts import ScreeningError
+from functions_analysis_access import AnalysisResultUnavailable
+from functions_appinsights import log_event
+from functions_workflow_result_store import WorkflowResultStorageUnavailableError
+from functions_workflow_runtime_store import RuntimeUnavailable
+
+
+_HISTORY_SOURCE_ERRORS = (PermissionError, LookupError, ValueError, AzureError,
+ WorkflowResultStorageUnavailableError, RuntimeUnavailable, ScreeningError)
+_UNAVAILABLE_HISTORY = "Saved workflow output is unavailable because current access could not be confirmed."
+
+
+def has_generated_artifact_source(metadata):
+ return (
+ "generated_artifact_source" in metadata
+ or bool(metadata.get("generated_artifact_source_required"))
+ or str(metadata.get("generated_artifact_idempotency_key") or "").startswith("generated-export:v1:")
+ )
+
+
+def generated_chat_artifact_address(owner_id, conversation_id, file_name, idempotency_key, blob_container):
+ suffix = uuid.uuid5(
+ uuid.NAMESPACE_URL, f"simplechat-generated-artifact:{conversation_id}:{idempotency_key}",
+ ).hex if idempotency_key else uuid.uuid4().hex
+ message_id = f"{conversation_id}_generated_file_{suffix}"
+ return {
+ "conversation_id": conversation_id, "artifact_message_id": message_id,
+ "file_name": file_name, "blob_container": blob_container,
+ "blob_path": f"{owner_id}/{conversation_id}/generated/{message_id}/{file_name}",
+ }
+
+
+def generated_artifact_source_metadata(source):
+ # Workflow stores are loaded only for an explicit workflow-source adapter.
+ from functions_workflow_artifacts import validate_workflow_artifact_binding
+
+ binding = validate_workflow_artifact_binding(source)
+ return {"generated_artifact_source_required": True, "generated_artifact_source": deepcopy(binding)}
+
+
+def authorize_generated_artifact_preparation(user_id, metadata):
+ from functions_workflow_artifacts import load_workflow_artifact_binding
+
+ if metadata.get("analysis_result_required") or metadata.get("analysis_producer"):
+ raise AnalysisResultUnavailable("generated_artifact_source_conflict")
+ return load_workflow_artifact_binding(
+ user_id, metadata.get("generated_artifact_source"), require_ready=False, for_publication=True,
+ )
+
+
+def authorize_generated_artifact_source(user_id, artifact, *, for_publication=False, native_authorizer=None):
+ metadata = artifact.get("metadata") or {}
+ if has_generated_artifact_source(metadata):
+ from functions_workflow_artifacts import authorize_workflow_saved_output_artifact
+
+ if metadata.get("analysis_result_required") or metadata.get("analysis_producer"):
+ raise AnalysisResultUnavailable("generated_artifact_source_conflict")
+ if metadata.get("generated_artifact_source_required") is not True:
+ raise AnalysisResultUnavailable("generated_artifact_source_unbound")
+ return authorize_workflow_saved_output_artifact(user_id, artifact, for_publication=for_publication)
+ if native_authorizer is None:
+ # Native sources retain their existing authorization and eligibility contract.
+ from functions_saved_analysis import authorize_analysis_artifact
+
+ native_authorizer = authorize_analysis_artifact
+ return native_authorizer(user_id, artifact, **({"for_publication": True} if for_publication else {}))
+
+
+def sanitize_generated_artifact_history(message, user_id):
+ """Reauthorize saved-output cards and strip private bindings from history."""
+ metadata = message.get("metadata") or {}
+ is_file = message.get("role") == "file" and has_generated_artifact_source(metadata)
+ fields = ("generated_analysis_artifacts", "generated_tabular_outputs")
+ cards = [
+ item for field in fields for item in metadata.get(field) or []
+ if isinstance(item, dict) and item.get("source_kind") == "workflow_saved_output"
+ ]
+ if not is_file and not cards:
+ return message
+ # Reuse the complete conversation/approval/source/screening boundary, not just an opaque id.
+ from route_enhanced_citations import _get_authorized_chat_artifact_message
+
+ def authorize(conversation_id, message_id):
+ request_context = has_request_context()
+ previous_error = getattr(g, "content_screening_error", None) if request_context else None
+ previous_sources = dict(getattr(g, "content_screening_sources", {}) or {}) if request_context else {}
+ try:
+ return _get_authorized_chat_artifact_message(user_id, conversation_id, message_id)
+ except _HISTORY_SOURCE_ERRORS:
+ if request_context:
+ g.content_screening_error = previous_error
+ g.content_screening_sources = previous_sources
+ raise
+
+ def log_unavailable(exc):
+ log_event(
+ "[SIMPLE_CHAT] Saved-output artifact withheld on history read",
+ {"message_id": message.get("id"), "exception_type": type(exc).__name__},
+ )
+
+ safe = deepcopy(message)
+ if is_file:
+ try:
+ authorize(message.get("conversation_id"), message.get("id"))
+ except _HISTORY_SOURCE_ERRORS as exc:
+ log_unavailable(exc)
+ return {
+ **{key: deepcopy(message[key]) for key in (
+ "id", "conversation_id", "timestamp", "created_at", "updated_at", "thread_id", "active_thread",
+ ) if key in message},
+ "role": "file", "content": _UNAVAILABLE_HISTORY, "content_unavailable": True,
+ "file_content": "", "extracted_text": "",
+ }
+ safe = {key: value for key, value in safe.items() if key in {
+ "id", "conversation_id", "role", "filename", "file_name", "content",
+ "timestamp", "created_at", "updated_at", "thread_id", "active_thread",
+ }}
+ safe["metadata"] = {
+ key: value for key, value in metadata.items() if key in {
+ "is_generated_chat_artifact", "generated_artifact_capability", "generated_artifact_output_format",
+ "generated_artifact_summary", "thread_info",
+ }
+ }
+ return safe
+ for field in fields:
+ if field not in metadata:
+ continue
+ projected = []
+ for card in metadata[field]:
+ if not isinstance(card, dict) or card.get("source_kind") != "workflow_saved_output":
+ projected.append(deepcopy(card))
+ continue
+ try:
+ if card.get("conversation_id") != message.get("conversation_id"):
+ raise AnalysisResultUnavailable("generated_artifact_source_unbound")
+ authorize(card["conversation_id"], card.get("artifact_message_id"))
+ except _HISTORY_SOURCE_ERRORS as exc:
+ log_unavailable(exc)
+ projected.append({
+ "capability": "file_export", "source_kind": "workflow_saved_output",
+ "output_format": "json", "status": "unavailable",
+ "summary": _UNAVAILABLE_HISTORY,
+ })
+ else:
+ projected.append({key: deepcopy(value) for key, value in card.items() if key in {
+ "capability", "source_kind", "artifact_message_id", "conversation_id", "storage_scope",
+ "file_name", "output_format", "summary", "suppress_assistant_text", "row_count", "row_source",
+ }})
+ safe["metadata"][field] = projected
+ return safe
diff --git a/application/single_app/functions_generated_file_exports.py b/application/single_app/functions_generated_file_exports.py
index 44850f17b..f4e6e0e2c 100644
--- a/application/single_app/functions_generated_file_exports.py
+++ b/application/single_app/functions_generated_file_exports.py
@@ -1,14 +1,16 @@
# functions_generated_file_exports.py
"""Format-neutral planning and rendering for generated chat file exports."""
+import hashlib
import html
import io
import json
import os
import re
import tempfile
+from dataclasses import dataclass
from datetime import datetime
-from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
+from typing import Any, BinaryIO, Callable, Dict, Iterable, Iterator, List, Optional, Protocol, Sequence, Tuple, overload
from xml.etree import ElementTree
from defusedxml import ElementTree as DefusedElementTree
@@ -36,6 +38,127 @@
ASSISTANT_TEXT_SUPPRESSING_FORMATS = {'json', 'xml'}
GENERATED_FILE_PREVIEW_ROWS = 3
REQUESTED_ARTIFACT_FORMATS = ('csv', 'json', 'xml', 'md', 'docx', 'pdf')
+GENERATED_RECORD_EXPORT_FORMATS = {'exact_records_v1': ('json',)}
+
+
+@dataclass(frozen=True)
+class GeneratedFileExportRequest:
+ output_format: str
+ profile: str = 'exact_records_v1'
+
+
+class GeneratedRecordExportSource(Protocol):
+ kind: str
+ record_count: int
+
+ def iter_records(self) -> Iterator[Dict[str, Any]]: ...
+
+ def recheck(self) -> None: ...
+
+
+@dataclass
+class GeneratedFileExportStream:
+ file_content: BinaryIO
+ output_format: str
+ media_type: str
+ size_bytes: int
+ content_sha256: str
+ record_count: int
+ profile: str
+
+ def close(self) -> None:
+ self.file_content.close()
+
+ def __enter__(self) -> "GeneratedFileExportStream":
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
+ self.close()
+
+
+def _validate_exact_json_value(value):
+ if type(value) is dict:
+ for key, child in value.items():
+ if type(key) is not str:
+ raise ValueError('Saved record object keys must be strings.')
+ _validate_exact_json_value(child)
+ elif type(value) is list:
+ for child in value:
+ _validate_exact_json_value(child)
+ elif type(value) not in {str, int, float, bool, type(None)}:
+ raise ValueError('Saved records must contain only finite JSON values.')
+
+
+def _build_generated_record_export(
+ source: GeneratedRecordExportSource, request: GeneratedFileExportRequest, *,
+ max_output_bytes: int, check: Optional[Callable[[], Any]],
+) -> GeneratedFileExportStream:
+ if (
+ not isinstance(request, GeneratedFileExportRequest)
+ or request.output_format not in GENERATED_RECORD_EXPORT_FORMATS.get(request.profile, ())
+ or source.kind != 'records'
+ ):
+ raise ValueError('This saved-output source and export format are not supported.')
+ if type(source.record_count) is not int or source.record_count < 0:
+ raise ValueError('The saved record count is invalid.')
+ if type(max_output_bytes) is not int or max_output_bytes < 1:
+ raise ValueError('A positive saved-output byte limit is required.')
+ stream = tempfile.TemporaryFile(mode='w+b')
+ digest = hashlib.sha256()
+ size = 0
+ checked_size = 0
+ count = 0
+ completed = False
+ encoder = json.JSONEncoder(sort_keys=True, separators=(',', ':'), ensure_ascii=True, allow_nan=False)
+
+ def write(fragment):
+ nonlocal size, checked_size
+ for offset in range(0, len(fragment), 65536):
+ chunk = fragment[offset:offset + 65536].encode('ascii')
+ if size + len(chunk) > max_output_bytes:
+ raise ValueError('The complete saved-output file exceeds the configured artifact size limit.')
+ stream.write(chunk)
+ digest.update(chunk)
+ size += len(chunk)
+ if check is not None and size - checked_size >= 65536:
+ check()
+ checked_size = size
+
+ try:
+ if check is not None:
+ check()
+ source.recheck()
+ write('[')
+ for record in source.iter_records():
+ if type(record) is not dict or count >= source.record_count:
+ raise ValueError('The saved record collection does not match its declared shape or count.')
+ _validate_exact_json_value(record)
+ if count:
+ write(',')
+ for fragment in encoder.iterencode(record):
+ write(fragment)
+ count += 1
+ if check is not None and count % 100 == 0:
+ check()
+ if count != source.record_count:
+ raise ValueError('The complete saved record count does not match the exported file.')
+ write(']')
+ source.recheck()
+ if check is not None:
+ check()
+ stream.seek(0)
+ completed = True
+ return GeneratedFileExportStream(
+ file_content=stream, output_format=request.output_format, media_type='application/json',
+ size_bytes=size, content_sha256=digest.hexdigest(), record_count=count, profile=request.profile,
+ )
+ except (TypeError, RecursionError) as exc:
+ raise ValueError('Saved records must contain only finite, bounded JSON values.') from exc
+ finally:
+ if not completed:
+ stream.close()
+
+
STRUCTURED_ARTIFACT_FORMAT_MARKERS = {
'json': (
'json artifact',
@@ -626,6 +749,23 @@ def build_saved_analysis_export(analysis_result, output_format):
}
+@overload
+def build_generated_file_export(
+ user_question: str = '',
+ assistant_content: str = '',
+ function_results: Optional[List[Dict[str, Any]]] = None,
+ prior_function_results_loader: Optional[Callable[[], Optional[List[Dict[str, Any]]]]] = None,
+ pending_output_format: Optional[str] = None,
+ analysis_result: Optional[Dict[str, Any]] = None,
+ *,
+ source: GeneratedRecordExportSource,
+ export_request: GeneratedFileExportRequest,
+ max_output_bytes: int,
+ check: Optional[Callable[[], Any]] = None,
+) -> GeneratedFileExportStream: ...
+
+
+@overload
def build_generated_file_export(
user_question: str,
assistant_content: str,
@@ -633,8 +773,31 @@ def build_generated_file_export(
prior_function_results_loader: Optional[Callable[[], Optional[List[Dict[str, Any]]]]] = None,
pending_output_format: Optional[str] = None,
analysis_result: Optional[Dict[str, Any]] = None,
-) -> Optional[Dict[str, Any]]:
- """Build a generated file payload from final assistant content and function-result evidence."""
+) -> Optional[Dict[str, Any]]: ...
+
+
+def build_generated_file_export(
+ user_question: str = '',
+ assistant_content: str = '',
+ function_results: Optional[List[Dict[str, Any]]] = None,
+ prior_function_results_loader: Optional[Callable[[], Optional[List[Dict[str, Any]]]]] = None,
+ pending_output_format: Optional[str] = None,
+ analysis_result: Optional[Dict[str, Any]] = None,
+ *,
+ source: Optional[GeneratedRecordExportSource] = None,
+ export_request: Optional[GeneratedFileExportRequest] = None,
+ max_output_bytes: Optional[int] = None,
+ check: Optional[Callable[[], Any]] = None,
+) -> Optional[Dict[str, Any]] | GeneratedFileExportStream:
+ """Render an explicit complete source, or preserve the existing response-export policy."""
+ if source is not None or export_request is not None:
+ if source is None or export_request is None or analysis_result is not None:
+ raise ValueError('An explicit export requires exactly one saved source and format request.')
+ if type(max_output_bytes) is not int or max_output_bytes < 1:
+ raise ValueError('A positive saved-output byte limit is required.')
+ return _build_generated_record_export(
+ source, export_request, max_output_bytes=max_output_bytes, check=check,
+ )
output_format = get_requested_generated_file_format(user_question) or _normalize_pending_output_format(
pending_output_format,
)
diff --git a/application/single_app/functions_personal_workflows.py b/application/single_app/functions_personal_workflows.py
index d3d9e1d53..2de9d8d56 100644
--- a/application/single_app/functions_personal_workflows.py
+++ b/application/single_app/functions_personal_workflows.py
@@ -42,7 +42,8 @@
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_publication_completion_policy, normalize_workflow_definition, workflow_definition_for_editor,
+ normalize_publication_completion_policy, normalize_publication_source_kind,
+ normalize_workflow_definition, workflow_definition_for_editor,
)
from functions_workflow_runtime_store import workflow_runtime_store
@@ -165,11 +166,11 @@ def _normalize_alert_priority(value):
def normalize_workflow_publication(publication):
- """Normalize an explicit existing-artifact request, never a workspace preference."""
+ """Normalize an explicit file publication request, never a workspace preference."""
if publication is None:
return None
if not isinstance(publication, dict) or set(publication) - {
- 'artifact_format', 'workspace_scope', 'group_id', 'public_workspace_id', 'completion_policy',
+ 'artifact_format', 'workspace_scope', 'group_id', 'public_workspace_id', 'completion_policy', 'source_kind',
}:
raise ValueError('Task publication must specify an artifact format and destination.')
output_format = _normalize_text(publication.get('artifact_format'), 'Artifact format', required=True).lower()
@@ -181,6 +182,10 @@ 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 'source_kind' in publication:
+ normalized['source_kind'] = normalize_publication_source_kind(publication['source_kind'])
+ if normalized['source_kind'] == 'saved_output' and output_format != 'json':
+ raise ValueError('Saved-output publication currently supports exact JSON records only.')
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)
diff --git a/application/single_app/functions_simplechat_operations.py b/application/single_app/functions_simplechat_operations.py
index f2f421b07..197452ce8 100644
--- a/application/single_app/functions_simplechat_operations.py
+++ b/application/single_app/functions_simplechat_operations.py
@@ -9,12 +9,14 @@
import re
import tempfile
import uuid
+from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional, Tuple
from urllib.parse import quote
import requests
-from azure.cosmos.exceptions import CosmosResourceNotFoundError
+from azure.core.exceptions import ResourceExistsError
+from azure.cosmos.exceptions import CosmosResourceExistsError, CosmosResourceNotFoundError
from flask import current_app, has_app_context, session
from collaboration_models import normalize_collaboration_user
@@ -68,6 +70,13 @@
requires_generated_file_approval,
user_can_approve_generated_file,
)
+from functions_generated_artifact_sources import (
+ authorize_generated_artifact_preparation,
+ authorize_generated_artifact_source,
+ generated_artifact_source_metadata,
+ generated_chat_artifact_address,
+ has_generated_artifact_source,
+)
from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version
from functions_group import (
assert_group_role,
@@ -1449,6 +1458,30 @@ def upload_generated_analysis_artifact_stream_for_user(
artifact_idempotency_key: str = "",
artifact_lifecycle_metadata: Optional[Dict[str, Any]] = None,
analysis_producer: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ """Compatibility entry point for real native Analyze producers."""
+ return upload_generated_file_artifact_stream_for_user(
+ current_user_id, conversation_id, file_name, file_stream, file_size,
+ capability=capability, output_format=output_format, summary=summary,
+ artifact_idempotency_key=artifact_idempotency_key,
+ artifact_lifecycle_metadata=artifact_lifecycle_metadata, analysis_producer=analysis_producer,
+ )
+
+
+def upload_generated_file_artifact_stream_for_user(
+ current_user_id: str,
+ conversation_id: str,
+ file_name: str,
+ file_stream: Any,
+ file_size: int,
+ capability: str = "analysis",
+ output_format: str = "",
+ summary: str = "",
+ artifact_idempotency_key: str = "",
+ artifact_lifecycle_metadata: Optional[Dict[str, Any]] = None,
+ analysis_producer: Optional[Dict[str, Any]] = None,
+ generated_artifact_source: Optional[Dict[str, Any]] = None,
+ execution_check=None,
) -> Dict[str, Any]:
"""Upload a bounded-memory generated artifact stream for an authorized user."""
normalized_user_id = str(current_user_id or "").strip()
@@ -1466,6 +1499,8 @@ def upload_generated_analysis_artifact_stream_for_user(
raise ValueError("file_stream must be seekable and readable")
if not allowed_file(normalized_file_name):
raise ValueError("Generated file type is not supported")
+ if generated_artifact_source is not None and analysis_producer is not None:
+ raise ValueError("A generated artifact must have exactly one producer binding.")
normalized_file_size = max(0, int(file_size or 0))
if normalized_file_size <= 0:
@@ -1496,8 +1531,11 @@ def upload_generated_analysis_artifact_stream_for_user(
"summary": normalized_summary,
**(artifact_lifecycle_metadata if isinstance(artifact_lifecycle_metadata, dict) else {}),
**analysis_artifact_metadata(analysis_producer),
+ **(generated_artifact_source_metadata(generated_artifact_source)
+ if generated_artifact_source is not None else {}),
},
artifact_idempotency_key=artifact_idempotency_key,
+ execution_check=execution_check,
)
@@ -2373,12 +2411,29 @@ def _write_temp_markdown_file(markdown_content: str) -> str:
return temp_file.name
-def _write_temp_generated_file(file_content_bytes: bytes, suffix: str) -> str:
+def _write_temp_generated_file(file_content_bytes: Any, suffix: str) -> str:
sc_temp_files_dir = "/sc-temp-files" if os.path.exists("/sc-temp-files") else None
normalized_suffix = suffix if suffix.startswith('.') else f'.{suffix}' if suffix else '.json'
with tempfile.NamedTemporaryFile(delete=False, suffix=normalized_suffix, dir=sc_temp_files_dir) as temp_file:
- temp_file.write(file_content_bytes)
- return temp_file.name
+ path = temp_file.name
+ complete = False
+ try:
+ if hasattr(file_content_bytes, "read") and hasattr(file_content_bytes, "seek"):
+ file_content_bytes.seek(0)
+ for chunk in iter(lambda: file_content_bytes.read(1024 * 1024), b""):
+ if not isinstance(chunk, bytes):
+ raise ValueError("A generated file stream must contain bytes.")
+ temp_file.write(chunk)
+ else:
+ temp_file.write(file_content_bytes)
+ if temp_file.tell() == 0:
+ raise ValueError("Generated file content is empty.")
+ complete = True
+ return path
+ finally:
+ if not complete:
+ temp_file.close()
+ os.remove(path)
def _queue_document_upload_background_task(
@@ -2443,12 +2498,14 @@ def queue_generated_document_processing(
if not normalized_name:
raise ValueError("normalized_file_name is required")
- if isinstance(file_content_bytes, bytes):
+ if hasattr(file_content_bytes, "read") and hasattr(file_content_bytes, "seek"):
+ normalized_file_content_bytes = file_content_bytes
+ elif isinstance(file_content_bytes, bytes):
normalized_file_content_bytes = file_content_bytes
else:
normalized_file_content_bytes = str(file_content_bytes or "").encode("utf-8")
- if not normalized_file_content_bytes.strip():
+ if isinstance(normalized_file_content_bytes, bytes) and not normalized_file_content_bytes.strip():
raise ValueError("file_content_bytes is required")
file_extension = os.path.splitext(normalized_name)[1].lower() or ".json"
@@ -3008,6 +3065,51 @@ def auto_deny_expired_generated_file_approvals() -> int:
return denied_count
+def _verify_generated_artifact_blob(blob_client, expected_digest, expected_size, *, check=None):
+ digest, size = hashlib.sha256(), 0
+ for chunk in blob_client.download_blob().chunks():
+ digest.update(chunk)
+ size += len(chunk)
+ if size > expected_size:
+ raise ValueError("The existing generated artifact contains different bytes.")
+ if check is not None:
+ check()
+ if size != expected_size or digest.hexdigest() != expected_digest:
+ raise ValueError("The existing generated artifact contains different bytes.")
+
+
+@contextmanager
+def open_generated_chat_artifact_stream(artifact, *, check=None):
+ """Verify an entire bound file before a caller hands off or returns its bytes."""
+ metadata = artifact.get("metadata") or {}
+ expected_size = metadata.get("generated_artifact_size_bytes")
+ expected_digest = metadata.get("generated_artifact_content_sha256")
+ if type(expected_size) is not int or expected_size < 1 or not expected_digest:
+ raise ValueError("The generated artifact byte binding is unavailable.")
+ blob_service_client = CLIENTS.get("storage_account_office_docs_client")
+ if not blob_service_client:
+ raise RuntimeError("Blob storage client not available")
+ blob_client = blob_service_client.get_blob_client(container=artifact["blob_container"], blob=artifact["blob_path"])
+ with tempfile.TemporaryFile(mode="w+b") as content:
+ digest, size = hashlib.sha256(), 0
+ if check is not None:
+ check()
+ for chunk in blob_client.download_blob().chunks():
+ size += len(chunk)
+ if size > expected_size:
+ raise ValueError("The generated artifact bytes changed.")
+ content.write(chunk)
+ digest.update(chunk)
+ if check is not None:
+ check()
+ if size != expected_size or digest.hexdigest() != expected_digest:
+ raise ValueError("The generated artifact bytes changed.")
+ if check is not None:
+ check()
+ content.seek(0)
+ yield content
+
+
def _upload_generated_chat_artifact_for_current_user(
current_user_id: str,
conversation_id: str,
@@ -3015,6 +3117,7 @@ def _upload_generated_chat_artifact_for_current_user(
file_content_bytes: bytes,
artifact_metadata: Optional[Dict[str, Any]] = None,
artifact_idempotency_key: str = "",
+ execution_check=None,
) -> Dict[str, Any]:
try:
conversation_item = cosmos_conversations_container.read_item(
@@ -3034,15 +3137,32 @@ def _upload_generated_chat_artifact_for_current_user(
analysis_metadata = analysis_artifact_metadata(artifact_metadata.get("analysis_producer"))
if artifact_metadata.get("analysis_result_required") and not analysis_metadata:
raise ValueError("The analysis artifact has no producer binding.")
+ source_context = (
+ authorize_generated_artifact_preparation(current_user_id, artifact_metadata)
+ if has_generated_artifact_source(artifact_metadata) else None
+ )
+ source_metadata = (
+ generated_artifact_source_metadata(source_context["binding"]) if source_context else {}
+ )
content_digest = hashlib.sha256()
+ content_size = 0
if hasattr(file_content_bytes, "read") and hasattr(file_content_bytes, "seek"):
file_content_bytes.seek(0)
for block in iter(lambda: file_content_bytes.read(1024 * 1024), b""):
content_digest.update(block)
+ content_size += len(block)
+ if execution_check is not None:
+ execution_check()
file_content_bytes.seek(0)
else:
content_digest.update(file_content_bytes)
+ content_size = len(file_content_bytes)
content_sha256 = content_digest.hexdigest()
+ if source_context and (
+ content_sha256 != source_context["descriptor"]["content_sha256"]
+ or content_size != source_context["descriptor"]["size_bytes"]
+ ):
+ raise ValueError("The prepared generated artifact contains different bytes.")
approval_metadata = {}
if requires_generated_file_approval(
access_context,
@@ -3059,22 +3179,49 @@ def _upload_generated_chat_artifact_for_current_user(
raise RuntimeError("Blob storage client not available")
normalized_idempotency_key = str(artifact_idempotency_key or "").strip()
- if normalized_idempotency_key:
- artifact_suffix = uuid.uuid5(
- uuid.NAMESPACE_URL,
- f"simplechat-generated-artifact:{conversation_id}:{normalized_idempotency_key}",
- ).hex
- else:
- artifact_suffix = uuid.uuid4().hex
- artifact_message_id = f"{conversation_id}_generated_file_{artifact_suffix}"
- blob_path = (
- f"{current_user_id}/{conversation_id}/generated/"
- f"{artifact_message_id}/{normalized_file_name}"
- )
+ address = source_context["descriptor"]["artifact"] if source_context else generated_chat_artifact_address(
+ current_user_id, conversation_id, normalized_file_name, normalized_idempotency_key,
+ storage_account_personal_chat_container_name,
+ )
+ if source_context and (
+ address["conversation_id"] != conversation_id or address["file_name"] != normalized_file_name
+ or normalized_idempotency_key != f"generated-export:v1:{source_context['binding']['export_key']}"
+ or artifact_metadata.get("output_format") != "json"
+ ):
+ raise ValueError("The generated artifact does not match its prepared address.")
+ artifact_message_id, blob_path = address["artifact_message_id"], address["blob_path"]
+ blob_container = address["blob_container"]
blob_client = blob_service_client.get_blob_client(
- container=storage_account_personal_chat_container_name,
+ container=blob_container,
blob=blob_path,
)
+
+ def reauthorize_write():
+ if execution_check is not None:
+ execution_check()
+ if source_context:
+ current_conversation = cosmos_conversations_container.read_item(
+ item=conversation_id, partition_key=conversation_id,
+ )
+ build_conversation_participation_context(current_user_id, current_conversation)
+ authorize_generated_artifact_preparation(current_user_id, artifact_metadata)
+
+ def check_existing(existing):
+ metadata = existing.get("metadata") or {}
+ if (
+ existing.get("role") != "file" or existing.get("conversation_id") != conversation_id
+ or existing.get("filename") != normalized_file_name or existing.get("blob_path") != blob_path
+ or existing.get("blob_container") != blob_container or existing.get("file_content_source") != "blob"
+ or any(metadata.get(key) != value for key, value in source_metadata.items())
+ or metadata.get("generated_artifact_content_sha256") != content_sha256
+ or metadata.get("generated_artifact_size_bytes") != content_size
+ or metadata.get("generated_artifact_idempotency_key") != normalized_idempotency_key
+ or metadata.get("generated_artifact_output_format") != "json"
+ ):
+ raise ValueError("The existing generated artifact has a different immutable binding.")
+ _verify_generated_artifact_blob(blob_client, content_sha256, content_size, check=execution_check)
+ reauthorize_write()
+
if normalized_idempotency_key:
try:
existing_message = cosmos_messages_container.read_item(
@@ -3083,6 +3230,8 @@ def _upload_generated_chat_artifact_for_current_user(
)
except CosmosResourceNotFoundError:
existing_message = None
+ if source_context and existing_message is not None:
+ check_existing(existing_message)
if (
isinstance(existing_message, dict)
and existing_message.get("role") == "file"
@@ -3099,23 +3248,27 @@ def _upload_generated_chat_artifact_for_current_user(
"message": {
"id": artifact_message_id,
"file_name": normalized_file_name,
- "blob_container": storage_account_personal_chat_container_name,
+ "blob_container": blob_container,
"blob_path": blob_path,
"capability": existing_metadata.get("generated_artifact_capability") or "analysis",
"output_format": existing_metadata.get("generated_artifact_output_format") or "",
},
"conversation_id": conversation_id,
}
- blob_client.upload_blob(
- file_content_bytes,
- overwrite=True,
- metadata={
- "conversation_id": conversation_id,
- "user_id": current_user_id,
- "generated_artifact": "true",
- "idempotent_artifact": str(bool(normalized_idempotency_key)).lower(),
- },
- )
+ reauthorize_write()
+ try:
+ blob_client.upload_blob(
+ file_content_bytes, overwrite=not bool(source_context),
+ metadata={
+ "conversation_id": conversation_id, "user_id": current_user_id,
+ "generated_artifact": "true",
+ "idempotent_artifact": str(bool(normalized_idempotency_key)).lower(),
+ },
+ )
+ except ResourceExistsError:
+ if not source_context:
+ raise
+ _verify_generated_artifact_blob(blob_client, content_sha256, content_size, check=execution_check)
timestamp = datetime.now(timezone.utc).isoformat()
current_thread_id = str(uuid.uuid4())
@@ -3137,7 +3290,7 @@ def _upload_generated_chat_artifact_for_current_user(
"filename": normalized_file_name,
"is_table": file_extension in TABULAR_EXTENSIONS,
"file_content_source": "blob",
- "blob_container": storage_account_personal_chat_container_name,
+ "blob_container": blob_container,
"blob_path": blob_path,
"timestamp": timestamp,
"model_deployment_name": None,
@@ -3150,6 +3303,8 @@ def _upload_generated_chat_artifact_for_current_user(
"generated_artifact_idempotency_key": normalized_idempotency_key or None,
"generated_artifact_content_sha256": content_sha256,
**analysis_metadata,
+ **source_metadata,
+ **({"generated_artifact_size_bytes": content_size} if source_context else {}),
**lifecycle_metadata,
**approval_metadata,
"thread_info": {
@@ -3160,9 +3315,19 @@ def _upload_generated_chat_artifact_for_current_user(
},
},
}
- cosmos_messages_container.upsert_item(message_doc)
+ reauthorize_write()
+ created = True
+ if source_context:
+ try:
+ cosmos_messages_container.create_item(message_doc)
+ except CosmosResourceExistsError:
+ message_doc = cosmos_messages_container.read_item(item=artifact_message_id, partition_key=conversation_id)
+ check_existing(message_doc)
+ created = False
+ else:
+ cosmos_messages_container.upsert_item(message_doc)
- if approval_metadata:
+ if approval_metadata and created:
_notify_generated_file_approval_requested(message_doc, access_context)
log_event(
@@ -3184,7 +3349,7 @@ def _upload_generated_chat_artifact_for_current_user(
"message": {
"id": artifact_message_id,
"file_name": normalized_file_name,
- "blob_container": storage_account_personal_chat_container_name,
+ "blob_container": blob_container,
"blob_path": blob_path,
"capability": artifact_capability,
"output_format": artifact_output_format,
@@ -3272,8 +3437,8 @@ def _generated_artifact_has_lifecycle_contract(metadata: Dict[str, Any]) -> bool
def assert_generated_chat_artifact_is_published_for_user(current_user_id: str, message_item: Dict[str, Any]) -> None:
"""Reauthorize a generated artifact against its committed artifact-set manifest."""
metadata = message_item.get("metadata") if isinstance(message_item.get("metadata"), dict) else {}
- if metadata.get("analysis_result_required") or metadata.get("analysis_result_contexts"):
- authorize_analysis_artifact(current_user_id, message_item)
+ if has_generated_artifact_source(metadata) or metadata.get("analysis_result_required") or metadata.get("analysis_result_contexts"):
+ authorize_generated_artifact_source(current_user_id, message_item, native_authorizer=authorize_analysis_artifact)
if not _generated_artifact_has_lifecycle_contract(metadata):
return
diff --git a/application/single_app/functions_workflow_artifacts.py b/application/single_app/functions_workflow_artifacts.py
new file mode 100644
index 000000000..bf2af6a1d
--- /dev/null
+++ b/application/single_app/functions_workflow_artifacts.py
@@ -0,0 +1,333 @@
+# functions_workflow_artifacts.py
+"""Authorized workflow record sources and fenced shared-export materialization."""
+
+from copy import deepcopy
+import re
+
+from azure.core.exceptions import ResourceNotFoundError
+from azure.cosmos.exceptions import CosmosResourceNotFoundError
+
+from functions_analysis_access import AnalysisResultUnavailable
+from functions_appinsights import log_event
+from functions_generated_artifact_sources import generated_chat_artifact_address
+from functions_generated_file_exports import GeneratedFileExportRequest, build_generated_file_export
+from functions_workflow_definitions import workflow_definition_revision
+from functions_workflow_identity import canonical_digest, workflow_execution_id, workflow_node_identity
+from functions_workflow_node_results import open_workflow_record_input, result_selectors
+from functions_workflow_result_store import _quota_bytes, load_workflow_node_result
+from functions_workflow_runtime_store import WorkflowRuntimeConflict, workflow_runtime_store
+
+
+EXPORT_CONTRACT = "generated-file-export-v1"
+EXPORT_PROFILE = "exact_records_v1"
+_DIGEST = re.compile(r"[0-9a-f]{64}\Z")
+_RECEIPT_FIELDS = ("producer", "result_ref", "output_name", "output_ref")
+
+
+def _source_receipt(value):
+ if (
+ not isinstance(value, dict) or not all(name in value for name in _RECEIPT_FIELDS)
+ or not all(isinstance(value[name], dict) for name in ("producer", "result_ref", "output_ref"))
+ or not isinstance(value["output_name"], str) or not value["output_name"]
+ ):
+ raise AnalysisResultUnavailable("generated_artifact_source_unbound")
+ return {name: deepcopy(value[name]) for name in _RECEIPT_FIELDS}
+
+
+def _scope(workflow):
+ return {"type": "group" if workflow.get("group_id") else "personal",
+ "id": workflow.get("group_id") or workflow["user_id"]}
+
+
+def _export_key(workflow, receipt, allow_partial):
+ return canonical_digest({
+ "contract": EXPORT_CONTRACT, "workflow_scope": _scope(workflow),
+ "definition_revision": workflow_definition_revision(workflow),
+ **_source_receipt(receipt), "allow_partial": allow_partial,
+ "profile": EXPORT_PROFILE, "output_format": "json",
+ })
+
+
+def _root_selectors(workflow, run_id):
+ root = workflow["flow"]["id"]
+ return {"node_id": root, "execution_id": workflow_execution_id(workflow, run_id, root),
+ "iteration_path": [], "attempt": 1}
+
+
+def validate_workflow_artifact_binding(value):
+ if (
+ not isinstance(value, dict) or value.keys() != {
+ "version", "kind", "scope", "producer", "source_receipt", "allow_partial",
+ "export_key", "profile", "output_format", "materialization",
+ }
+ or type(value.get("version")) is not int or value["version"] != 1
+ or value.get("kind") != "workflow_saved_output"
+ or value.get("profile") != EXPORT_PROFILE or value.get("output_format") != "json"
+ or type(value.get("allow_partial")) is not bool
+ or not isinstance(value.get("export_key"), str) or not _DIGEST.fullmatch(value["export_key"])
+ ):
+ raise AnalysisResultUnavailable("generated_artifact_source_unbound")
+ scope, materialization = value["scope"], value["materialization"]
+ if (
+ not isinstance(scope, dict) or scope.keys() != {"type", "id"}
+ or scope.get("type") not in {"personal", "group"}
+ or not isinstance(scope.get("id"), str) or not scope["id"] or len(scope["id"]) > 1024
+ or not isinstance(materialization, dict) or materialization.keys() != {"descriptor_ref"}
+ or not isinstance(materialization["descriptor_ref"], dict)
+ or value["producer"] != _source_receipt(value["source_receipt"])["producer"]
+ ):
+ raise AnalysisResultUnavailable("generated_artifact_source_unbound")
+ producer = value["producer"]
+ if not all(isinstance(producer.get(key), str) and producer[key] for key in (
+ "workflow_id", "run_id", "node_id", "execution_id",
+ )):
+ raise AnalysisResultUnavailable("generated_artifact_source_unbound")
+ return deepcopy(value)
+
+
+def _authorize_workflow_scope(user_id, binding):
+ # Application stores and current membership are resolved only at the access boundary.
+ from functions_group import assert_group_role, check_group_status_allows_operation, find_group_by_id
+ from functions_group_workflows import get_group_workflow, get_group_workflow_run
+ from functions_personal_workflows import get_personal_workflow, get_personal_workflow_run
+
+ scope, producer = binding["scope"], binding["producer"]
+ if scope["type"] == "group":
+ assert_group_role(user_id, scope["id"], allowed_roles=("Owner", "Admin", "DocumentManager", "User"))
+ allowed, _ = check_group_status_allows_operation(find_group_by_id(scope["id"]), "view")
+ if not allowed:
+ raise AnalysisResultUnavailable("generated_artifact_source_unavailable")
+ workflow = get_group_workflow(scope["id"], producer["workflow_id"])
+ run = get_group_workflow_run(scope["id"], producer["run_id"])
+ else:
+ if scope["id"] != user_id:
+ raise AnalysisResultUnavailable("generated_artifact_source_unavailable")
+ workflow = get_personal_workflow(user_id, producer["workflow_id"])
+ run = get_personal_workflow_run(user_id, producer["run_id"])
+ if (
+ not workflow or workflow.get("id") != producer["workflow_id"] or _scope(workflow) != scope
+ or not run or run.get("id") != producer["run_id"] or run.get("workflow_id") != workflow["id"]
+ ):
+ raise AnalysisResultUnavailable("generated_artifact_source_unavailable")
+ return workflow, run
+
+
+class WorkflowRecordExportSource:
+ kind = "records"
+
+ def __init__(self, workflow, run_id, receipt, *, actor_user_id, store, allow_partial=False,
+ load_result=load_workflow_node_result, inspection=False):
+ self.workflow, self.run_id, self.store = workflow, run_id, store
+ self.actor_user_id = actor_user_id
+ self.receipt = _source_receipt(receipt)
+ self.allow_partial = allow_partial
+ identity = self.receipt["producer"]
+ expected = workflow_node_identity(
+ workflow, run_id, identity.get("node_id"), identity.get("execution_id"), identity.get("attempt"),
+ task_id=identity.get("task_id"), iteration_path=identity.get("iteration_path"),
+ )
+ if identity != expected:
+ raise AnalysisResultUnavailable("generated_artifact_source_unbound")
+ self._check_scope()
+ self._check_attempt()
+ self.reader = open_workflow_record_input(
+ workflow, run_id, identity, self.receipt["result_ref"], output_name=self.receipt["output_name"],
+ reader_user_id=actor_user_id, allow_partial=allow_partial, load_result=load_result, inspection=inspection,
+ )
+ if self.reader.kind != "records" or _source_receipt(self.reader.receipt) != self.receipt:
+ raise AnalysisResultUnavailable("generated_artifact_source_unbound")
+ self.record_count = self.reader.record_count
+
+ def _check_attempt(self):
+ identity = self.receipt["producer"]
+ row = self.store.journal_read("attempt", [identity["execution_id"], identity["attempt"]])
+ payload = row["payload"] if row else {}
+ summary = payload.get("workflow_result") or {}
+ if (
+ payload.get("state") not in {"completed", "succeeded"}
+ or any(payload.get(key) != identity.get(key) for key in (
+ "node_id", "execution_id", "iteration_path", "attempt", "task_id",
+ ))
+ or summary.get("producer") != identity or summary.get("result_ref") != self.receipt["result_ref"]
+ ):
+ raise AnalysisResultUnavailable("generated_artifact_source_uncommitted")
+
+ def _check_scope(self):
+ _authorize_workflow_scope(self.actor_user_id, {
+ "scope": _scope(self.workflow), "producer": self.receipt["producer"],
+ })
+
+ def recheck(self) -> None:
+ self._check_scope()
+ self._check_attempt()
+ self.reader.recheck()
+
+ def iter_records(self):
+ for index, record in enumerate(self.reader.iter_records()):
+ if index % 100 == 0:
+ self._check_scope()
+ self._check_attempt()
+ yield record
+
+
+def load_workflow_artifact_binding(user_id, value, *, require_ready=True, for_publication=False):
+ try:
+ return _load_workflow_artifact_binding(
+ user_id, value, require_ready=require_ready, for_publication=for_publication,
+ )
+ except (ValueError, WorkflowRuntimeConflict, ResourceNotFoundError, CosmosResourceNotFoundError) as exc:
+ log_event(
+ "[SIMPLE_CHAT] Saved-output artifact source unavailable",
+ {"exception_type": type(exc).__name__},
+ )
+ raise AnalysisResultUnavailable("generated_artifact_source_unavailable") from exc
+
+
+def _load_workflow_artifact_binding(user_id, value, *, require_ready, for_publication):
+ binding = validate_workflow_artifact_binding(value)
+ workflow, run = _authorize_workflow_scope(user_id, binding)
+ store = workflow_runtime_store(workflow, binding["producer"]["run_id"])
+ workflow = store.run_definition()
+ run_id, key = binding["producer"]["run_id"], binding["export_key"]
+ if _scope(workflow) != binding["scope"] or key != _export_key(workflow, binding["source_receipt"], binding["allow_partial"]):
+ raise AnalysisResultUnavailable("generated_artifact_source_unbound")
+ source = WorkflowRecordExportSource(
+ workflow, run_id, binding["source_receipt"], actor_user_id=user_id, store=store,
+ allow_partial=binding["allow_partial"], inspection=not for_publication,
+ )
+ selectors = _root_selectors(workflow, run_id)
+ reference = binding["materialization"]["descriptor_ref"]
+ prepared = store.journal_read("unit", ["generated-file-prepare", key])
+ expected_prepared = {"state": "completed", "selectors": selectors, "result_ref": reference}
+ if not prepared or prepared["payload"] != expected_prepared:
+ raise AnalysisResultUnavailable("generated_artifact_source_uncommitted")
+ descriptor = load_workflow_node_result(workflow, run_id, None, reference, **selectors)
+ if not isinstance(descriptor, dict):
+ raise AnalysisResultUnavailable("generated_artifact_source_unbound")
+ expected_source = {name: field for name, field in binding.items() if name != "materialization"}
+ address = descriptor.get("artifact") or {}
+ if (
+ descriptor.get("contract_version") != EXPORT_CONTRACT or descriptor.get("source") != expected_source
+ or address.get("conversation_id") != run.get("conversation_id")
+ or not address.get("blob_container") or not address.get("conversation_id")
+ or descriptor.get("record_count") != source.record_count
+ or type(descriptor.get("size_bytes")) is not int or descriptor["size_bytes"] < 2
+ or not isinstance(descriptor.get("content_sha256"), str) or not _DIGEST.fullmatch(descriptor["content_sha256"])
+ or address != generated_chat_artifact_address(
+ workflow["user_id"], address.get("conversation_id"), f"workflow-output-{key}.json",
+ f"generated-export:v1:{key}", address.get("blob_container"),
+ )
+ ):
+ raise AnalysisResultUnavailable("generated_artifact_source_unbound")
+ if require_ready:
+ ready = store.journal_read("unit", ["generated-file-ready", key])
+ if not ready or ready["payload"] != {"state": "completed", "descriptor_ref": reference}:
+ raise AnalysisResultUnavailable("generated_artifact_source_uncommitted")
+ return {"binding": binding, "descriptor": descriptor, "source": source}
+
+
+def authorize_workflow_saved_output_artifact(user_id, artifact, *, for_publication=False):
+ metadata = artifact.get("metadata") or {}
+ context = load_workflow_artifact_binding(
+ user_id, metadata.get("generated_artifact_source"), for_publication=for_publication,
+ )
+ descriptor, binding = context["descriptor"], context["binding"]
+ expected = descriptor["artifact"]
+ if (
+ artifact.get("id") != expected["artifact_message_id"]
+ or artifact.get("filename") != expected["file_name"] or artifact.get("role") != "file"
+ or any(artifact.get(key) != expected[key] for key in ("conversation_id", "blob_container", "blob_path"))
+ or metadata.get("generated_artifact_content_sha256") != descriptor["content_sha256"]
+ or metadata.get("generated_artifact_size_bytes") != descriptor["size_bytes"]
+ or metadata.get("generated_artifact_output_format") != "json"
+ or metadata.get("generated_artifact_idempotency_key") != f"generated-export:v1:{binding['export_key']}"
+ ):
+ raise AnalysisResultUnavailable("generated_artifact_source_unbound")
+ return context
+
+
+def materialize_workflow_saved_output(execution, receipt, *, actor_user_id, conversation_id,
+ allow_partial=False, upload=None, artifact_container=None):
+ """Commit a shared exact export without creating a task, scheduler, or publication ledger."""
+ workflow, run_id = execution.workflow, execution.run_id
+ execution.check()
+ source = WorkflowRecordExportSource(
+ workflow, run_id, receipt, actor_user_id=actor_user_id, store=execution.store,
+ allow_partial=allow_partial, load_result=execution.load_result,
+ )
+ key = _export_key(workflow, source.receipt, allow_partial)
+ prepared_key, ready_key = ["generated-file-prepare", key], ["generated-file-ready", key]
+ prepared = execution.store.journal_read("unit", prepared_key)
+ selectors = _root_selectors(workflow, run_id)
+ descriptor, reference = None, None
+ if prepared:
+ if (
+ prepared["payload"].get("state") != "completed" or prepared["payload"].get("selectors") != selectors
+ or not isinstance(prepared["payload"].get("result_ref"), dict)
+ ):
+ raise AnalysisResultUnavailable("generated_artifact_source_unbound")
+ reference = prepared["payload"]["result_ref"]
+ descriptor = execution.load_result(workflow, run_id, None, reference, **selectors)
+ if descriptor is None or execution.store.journal_read("unit", ready_key) is None:
+ if artifact_container is None:
+ # Reuse the configured private generated-file transport at materialization time.
+ from config import storage_account_personal_chat_container_name
+
+ artifact_container = storage_account_personal_chat_container_name
+ if upload is None:
+ from functions_simplechat_operations import upload_generated_file_artifact_stream_for_user
+
+ upload = upload_generated_file_artifact_stream_for_user
+ base_binding = {
+ "version": 1, "kind": "workflow_saved_output", "scope": _scope(workflow),
+ "producer": deepcopy(source.receipt["producer"]), "source_receipt": source.receipt,
+ "allow_partial": allow_partial, "export_key": key, "profile": EXPORT_PROFILE, "output_format": "json",
+ }
+ with build_generated_file_export(
+ source=source, export_request=GeneratedFileExportRequest("json", EXPORT_PROFILE),
+ max_output_bytes=_quota_bytes(execution.settings), check=execution.check,
+ ) as exported:
+ candidate = {
+ "contract_version": EXPORT_CONTRACT, "source": base_binding,
+ "artifact": generated_chat_artifact_address(
+ workflow["user_id"], conversation_id, f"workflow-output-{key}.json",
+ f"generated-export:v1:{key}", artifact_container,
+ ),
+ "content_sha256": exported.content_sha256, "size_bytes": exported.size_bytes,
+ "record_count": exported.record_count,
+ "validation_status": (source.reader.manifest.get("workflow_validation") or {}).get("status"),
+ }
+ if descriptor is not None and candidate != descriptor:
+ raise AnalysisResultUnavailable("generated_artifact_content_changed")
+ if descriptor is None:
+ descriptor = candidate
+ reference = execution.save_result(
+ workflow, run_id, None, descriptor, settings=execution.settings, **selectors,
+ )
+ execution.store.journal_commit(execution.lease.token, "unit", prepared_key, {
+ "state": "completed", "selectors": selectors, "result_ref": reference,
+ }, immutable=True)
+ binding = {**base_binding, "materialization": {"descriptor_ref": reference}}
+ execution.check()
+ source.recheck()
+ upload(
+ actor_user_id, conversation_id, descriptor["artifact"]["file_name"],
+ exported.file_content, exported.size_bytes, capability="file_export", output_format="json",
+ summary=f"{exported.record_count} exact saved records ({descriptor['validation_status']}).",
+ artifact_idempotency_key=f"generated-export:v1:{key}", generated_artifact_source=binding,
+ execution_check=execution.check,
+ )
+ execution.check()
+ source.recheck()
+ execution.store.journal_commit(execution.lease.token, "unit", ready_key, {
+ "state": "completed", "descriptor_ref": reference,
+ }, immutable=True)
+ binding = {**descriptor["source"], "materialization": {"descriptor_ref": reference}}
+ load_workflow_artifact_binding(actor_user_id, binding, for_publication=True)
+ source.recheck()
+ execution.check()
+ return {
+ **descriptor["artifact"], "output_format": "json", "capability": "file_export",
+ "record_count": descriptor["record_count"], "content_sha256": descriptor["content_sha256"],
+ "validation_status": descriptor["validation_status"], "source_binding": binding,
+ }
diff --git a/application/single_app/functions_workflow_definitions.py b/application/single_app/functions_workflow_definitions.py
index e349a5943..5b45c3b8c 100644
--- a/application/single_app/functions_workflow_definitions.py
+++ b/application/single_app/functions_workflow_definitions.py
@@ -15,6 +15,7 @@
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_PUBLICATION_SOURCE_KINDS = ("native_analysis", "saved_output")
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",
@@ -52,12 +53,27 @@ def normalize_publication_completion_policy(value):
return value
+def normalize_publication_source_kind(value):
+ if not isinstance(value, str) or value not in WORKFLOW_PUBLICATION_SOURCE_KINDS:
+ raise WorkflowDefinitionError("Publication source must be native_analysis or saved_output.")
+ 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:
+ if not isinstance(publication, dict):
+ continue
+ if "source_kind" in publication:
+ normalize_publication_source_kind(publication["source_kind"])
+ if publication.get("source_kind") == "saved_output":
+ if workflow.get("definition_version") != 3 or workflow.get("durable_execution") is not True:
+ raise WorkflowDefinitionError("Saved-output publication requires a version-3 durable workflow.")
+ if publication.get("artifact_format") != "json":
+ raise WorkflowDefinitionError("Saved-output publication currently supports exact JSON records only.")
+ if "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.")
diff --git a/application/single_app/functions_workflow_editor.py b/application/single_app/functions_workflow_editor.py
index d32cfa70e..1a785c37d 100644
--- a/application/single_app/functions_workflow_editor.py
+++ b/application/single_app/functions_workflow_editor.py
@@ -2,6 +2,7 @@
"""Non-secret editor choices and trusted loop-runner eligibility."""
from functions_ai_connections import supports_model_capability
+from functions_generated_file_exports import GENERATED_RECORD_EXPORT_FORMATS
from functions_workflow_definitions import (
WORKFLOW_DEFINITION_VERSION, WORKFLOW_INPUT_PROCESSING_MODES, WORKFLOW_PUBLICATION_COMPLETION_POLICIES,
)
@@ -63,6 +64,12 @@ def build_workflow_editor_options(*, scope_type, scope_id, can_manage, max_tasks
"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),
+ "publication_source_capabilities": [
+ {"source_kind": "native_analysis", "output_kinds": ["records", "json", "text", "document_results"],
+ "artifact_formats": ["md", "csv", "json"]},
+ {"source_kind": "saved_output", "output_kinds": ["records"],
+ "artifact_formats": list(GENERATED_RECORD_EXPORT_FORMATS["exact_records_v1"])},
+ ],
"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 4c142c7c9..ca4f676ab 100644
--- a/application/single_app/functions_workflow_flow.py
+++ b/application/single_app/functions_workflow_flow.py
@@ -641,8 +641,17 @@ def analyze(current, initial, *, branch=False):
after_possible.update({(node["id"], output) for output in WORKFLOW_BINDABLE_OUTPUTS})
if "run_when" not in node:
after_definite.update(keys)
- if catalogue[node["task_id"]].get("publication") is not None and len(bindings) != 1:
- raise WorkflowDefinitionError("A v3 publication task requires exactly one explicit upstream input.")
+ publication = catalogue[node["task_id"]].get("publication")
+ if publication is not None:
+ if len(bindings) != 1:
+ raise WorkflowDefinitionError("A v3 publication task requires exactly one explicit upstream input.")
+ if isinstance(publication, dict) and publication.get("source_kind") == "saved_output" and (
+ bindings[0]["source"]["kind"] != "node_output"
+ or bindings[0]["required"] is not True
+ or bindings[0]["expected_kind"] != "records"
+ or collection_kind(bindings[0]["source"]) != "records"
+ ):
+ raise WorkflowDefinitionError("Saved-output publication requires one required records node-output input.")
elif kind == "if":
ends = {name: analyze(node[name], (set(definite), set(possible)), branch=True)
for name in ("then", "else")}
diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py
index 13a1409f2..4be576747 100644
--- a/application/single_app/functions_workflow_runner.py
+++ b/application/single_app/functions_workflow_runner.py
@@ -10423,14 +10423,21 @@ def raise_if_cancelled():
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
+ # Completed loops are not yielded on replay; their display ordinal is
+ # not part of a saved-output publication's immutable authored input.
+ publication_task = (
+ {key: value for key, value in task.items() if key != 'order'}
+ if task['publication'].get('source_kind') == 'saved_output' else task
+ )
task_result, consumed_inputs = workflow_unit(
task_unit_key,
lambda: _execute_workflow_analysis_publication(
workflow, run_id, task, previous_task_id, previous_result_ref,
actor_user_id=actor_user_id,
+ conversation_id=conversation_id,
explicit_inputs=publication_inputs['bound_inputs'] if publication_inputs else None,
),
- inputs=({'task': task, 'consumed_inputs': publication_inputs['consumed_inputs']}
+ inputs=({'task': publication_task, 'consumed_inputs': publication_inputs['consumed_inputs']}
if publication_inputs is not None else
{'task': task, 'producer_task_id': previous_task_id, 'result_ref': previous_result_ref}),
approval=task.get('approval'),
@@ -10989,7 +10996,7 @@ def save_analysis_section(bound_workflow, bound_run, bound_task, section, **_kwa
def _execute_workflow_analysis_publication(
workflow, run_id, task, previous_task_id, previous_result_ref, *, actor_user_id=None,
- result_reader=None, publish=None, explicit_inputs=None,
+ result_reader=None, publish=None, explicit_inputs=None, conversation_id=None,
):
"""Select only a committed ancestor artifact, before resolving a model or its context."""
publication = task.get('publication')
@@ -11002,6 +11009,63 @@ def _execute_workflow_analysis_publication(
publication = normalize_workflow_publication(publication)
reader = result_reader or authorize_workflow_task_result_read
actor = actor_user_id or workflow.get('user_id')
+
+ def complete_response(result, request_id):
+ state = (result.get('publication') or {}).get('state')
+ if 'completion_policy' in publication:
+ execution = current_workflow_execution()
+ result['_publication_reference'] = {
+ 'kind': 'artifact_publication', 'version': 1, 'execution_id': execution.execution_id(),
+ 'attempt': execution.selectors()['attempt'], 'request_id': 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'
+ return result
+
+ if publication.get('source_kind') == 'saved_output':
+ from functions_artifact_publication import publish_workflow_artifact
+ from functions_generated_file_exports import build_generated_file_artifact_metadata
+ from functions_workflow_artifacts import materialize_workflow_saved_output
+
+ execution = current_workflow_execution()
+ bindings = task.get('inputs') or []
+ if (
+ workflow.get('definition_version') != 3 or execution is None or not conversation_id
+ or not isinstance(explicit_inputs, list) or len(explicit_inputs) != 1 or len(bindings) != 1
+ or bindings[0].get('required') is not True or bindings[0].get('expected_kind') != 'records'
+ or (bindings[0].get('source') or {}).get('kind') != 'node_output'
+ ):
+ raise WorkflowResultNotReadyError('Saved-output publication requires one exact records input and the current durable execution.')
+ receipt = explicit_inputs[0]
+ artifact = materialize_workflow_saved_output(
+ execution, receipt, actor_user_id=actor, conversation_id=conversation_id,
+ allow_partial=bindings[0].get('allow_partial', False),
+ )
+ producer = receipt['producer']
+ request_id = f"workflow-publication:v3:{execution.execution_id()}:{producer['execution_id']}:{producer['attempt']}"
+ result = (publish or publish_workflow_artifact)(
+ actor, publication=publication,
+ artifact_reference={
+ 'conversation_id': conversation_id, 'artifact_message_id': artifact['artifact_message_id'],
+ 'producer': {'kind': 'workflow_saved_output', **producer},
+ },
+ request_id=request_id, source_receipt=receipt, execution_check=execution.check,
+ )
+ public_artifact = build_generated_file_artifact_metadata(
+ {'file_name': artifact['file_name'], 'output_format': 'json', 'capability': 'file_export',
+ 'row_source': 'saved_records', 'row_count': artifact['record_count'],
+ 'summary': f"{artifact['record_count']} exact saved records ({artifact['validation_status']})."},
+ {'message': {'id': artifact['artifact_message_id'], 'file_name': artifact['file_name']}},
+ conversation_id,
+ )
+ public_artifact.update(
+ row_count=artifact['record_count'], suppress_assistant_text=False, source_kind='workflow_saved_output',
+ )
+ result['generated_tabular_outputs'] = [public_artifact]
+ return complete_response(result, request_id), [receipt]
if workflow.get('definition_version') == 3:
if not isinstance(explicit_inputs, list) or len(explicit_inputs) != 1:
raise WorkflowResultNotReadyError('Publication requires exactly one explicit saved analysis input.')
@@ -11092,18 +11156,7 @@ def _execute_workflow_analysis_publication(
request_id=publication_request_id,
**completion_options,
)
- state = (result.get('publication') or {}).get('state')
- 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'
- return result, (
+ return complete_response(result, publication_request_id), (
[*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/route_backend_conversation_export.py b/application/single_app/route_backend_conversation_export.py
index b79e614ba..44e91c84d 100644
--- a/application/single_app/route_backend_conversation_export.py
+++ b/application/single_app/route_backend_conversation_export.py
@@ -2193,7 +2193,10 @@ def _load_export_message_for_user(user_id: str, conversation_id: str, message_id
for analysis_context in analysis_result_contexts(message):
load_saved_analysis(user_id, analysis_context)
if message.get('role') == 'file':
- authorize_analysis_artifact(user_id, message)
+ # File exports retain the same source boundary as direct artifact downloads.
+ from functions_generated_artifact_sources import authorize_generated_artifact_source
+
+ authorize_generated_artifact_source(user_id, message, native_authorizer=authorize_analysis_artifact)
if isinstance(message.get('agent_citations'), list) and any(
isinstance(citation, dict) and citation.get('artifact_id')
diff --git a/application/single_app/route_enhanced_citations.py b/application/single_app/route_enhanced_citations.py
index 10a3b7118..ce2e50f79 100644
--- a/application/single_app/route_enhanced_citations.py
+++ b/application/single_app/route_enhanced_citations.py
@@ -2,6 +2,7 @@
# Backend endpoints for enhanced citations supporting different media types
from flask import jsonify, request, Response
+from contextlib import ExitStack
from datetime import datetime, timedelta, timezone
import hashlib
import logging
@@ -37,6 +38,7 @@
from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings
from functions_collaboration import build_conversation_participation_context
from functions_generated_file_approvals import assert_generated_file_approval_allows_download
+from functions_generated_artifact_sources import authorize_generated_artifact_source, has_generated_artifact_source
from functions_saved_analysis import authorize_analysis_artifact
from functions_simplechat_operations import (
assert_generated_chat_artifact_is_published_for_user,
@@ -215,34 +217,48 @@ def _build_content_disposition(disposition, file_name, fallback='download'):
def _serve_chat_artifact_download(user_id, conversation_id, message_id):
"""Read an authorized chat artifact, not a workspace document's representation."""
artifact = _get_authorized_chat_artifact_message(user_id, conversation_id, message_id)
- active_document = None
- if artifact.get('workspace_document_id'):
- active_document, content = read_available_document_bytes(
- artifact['workspace_document_id'], user_id=user_id, purpose='chat_file',
+ with ExitStack() as resources:
+ active_document = None
+ streamed = False
+ if artifact.get('workspace_document_id'):
+ active_document, content = read_available_document_bytes(
+ artifact['workspace_document_id'], user_id=user_id, purpose='chat_file',
+ )
+ elif has_generated_artifact_source(artifact.get('metadata') or {}):
+ # This explicit source can exceed the old in-memory artifact path.
+ from functions_simplechat_operations import open_generated_chat_artifact_stream
+
+ content = resources.enter_context(open_generated_chat_artifact_stream(artifact))
+ streamed = True
+ else:
+ content = download_blob_content(artifact['blob_container'], artifact['blob_path'])
+ current = _get_authorized_chat_artifact_message(user_id, conversation_id, message_id)
+ identity_fields = ('id', 'conversation_id', 'workspace_document_id', 'blob_container', 'blob_path', 'filename', '_etag')
+ digest = (artifact.get('metadata') or {}).get('generated_artifact_content_sha256')
+ current_digest = (current.get('metadata') or {}).get('generated_artifact_content_sha256')
+ if any(artifact.get(field) != current.get(field) for field in identity_fields) or digest != current_digest:
+ raise LookupError('The artifact changed during download.')
+ if not streamed and active_document is None and digest and hashlib.sha256(content).hexdigest() != digest:
+ raise LookupError('The artifact content changed.')
+
+ file_name = (active_document or {}).get('file_name') or _resolve_generated_artifact_file_name(current)
+ content_type = {
+ '.csv': 'text/csv; charset=utf-8',
+ '.md': 'text/markdown; charset=utf-8',
+ '.json': 'application/json',
+ }.get(os.path.splitext(file_name)[1].lower()) or mimetypes.guess_type(file_name)[0] or 'application/octet-stream'
+ response = Response(
+ iter(lambda: content.read(65536), b'') if streamed else content,
+ content_type=content_type, headers={
+ 'Content-Length': str(current['metadata']['generated_artifact_size_bytes'] if streamed else len(content)),
+ 'Content-Disposition': _build_content_disposition('attachment', file_name),
+ 'Cache-Control': 'private, no-store',
+ 'X-Content-Type-Options': 'nosniff',
+ },
)
- else:
- content = download_blob_content(artifact['blob_container'], artifact['blob_path'])
- current = _get_authorized_chat_artifact_message(user_id, conversation_id, message_id)
- identity_fields = ('id', 'conversation_id', 'workspace_document_id', 'blob_container', 'blob_path', 'filename', '_etag')
- digest = (artifact.get('metadata') or {}).get('generated_artifact_content_sha256')
- current_digest = (current.get('metadata') or {}).get('generated_artifact_content_sha256')
- if any(artifact.get(field) != current.get(field) for field in identity_fields) or digest != current_digest:
- raise LookupError('The artifact changed during download.')
- if active_document is None and digest and hashlib.sha256(content).hexdigest() != digest:
- raise LookupError('The artifact content changed.')
-
- file_name = (active_document or {}).get('file_name') or _resolve_generated_artifact_file_name(current)
- content_type = {
- '.csv': 'text/csv; charset=utf-8',
- '.md': 'text/markdown; charset=utf-8',
- '.json': 'application/json',
- }.get(os.path.splitext(file_name)[1].lower()) or mimetypes.guess_type(file_name)[0] or 'application/octet-stream'
- return Response(content, content_type=content_type, headers={
- 'Content-Length': str(len(content)),
- 'Content-Disposition': _build_content_disposition('attachment', file_name),
- 'Cache-Control': 'private, no-store',
- 'X-Content-Type-Options': 'nosniff',
- })
+ if streamed:
+ response.call_on_close(resources.pop_all().close)
+ return response
def _log_enhanced_citations_debug(message, **details):
@@ -735,7 +751,9 @@ def promote_chat_artifact_to_workspace():
if not payload.get("workspace_scope"):
raise ValueError("Choose an explicit destination for this analysis artifact.")
- authorize_analysis_artifact(user_id, message_item, for_publication=True)
+ authorize_generated_artifact_source(
+ user_id, message_item, for_publication=True, native_authorizer=authorize_analysis_artifact,
+ )
file_name = _resolve_generated_artifact_file_name(message_item)
requester_display_name = (
str(current_user_info.get("displayName") or "").strip()
diff --git a/application/v2_ui/src/components/chat/GeneratedArtifactCard.tsx b/application/v2_ui/src/components/chat/GeneratedArtifactCard.tsx
index dbbd35ded..14165329e 100644
--- a/application/v2_ui/src/components/chat/GeneratedArtifactCard.tsx
+++ b/application/v2_ui/src/components/chat/GeneratedArtifactCard.tsx
@@ -294,6 +294,7 @@ export function GeneratedArtifactCard({
const summary = text(artifact.summary);
const running = Boolean(artifact.background_export);
const compact = isCompletedTabularArtifact(artifact);
+ const savedRecordsExport = artifact.capability === 'file_export' && artifact.row_source === 'saved_records';
const downloadUrl = generatedArtifactDownloadUrl(artifact, conversationId);
const sourceNote = [
@@ -424,6 +425,9 @@ export function GeneratedArtifactCard({
{summary &&
{summary}
}
>
)}
+ {compact && !running && savedRecordsExport && summary && (
+ {summary}
+ )}
{running && (
;
allowLoopItems?: boolean;
+ recordsOnly?: boolean;
}) {
const available = availableIds ?? analyzeWorkflowFlow(workflow).available.get(nodeId) ?? new Set();
- const producers = flowProducers(workflow).filter((producer) => available.has(producer.id));
- const loops = allowLoopItems ? enclosingFlowLoops(workflow, nodeId) : [];
+ const producers = flowProducers(workflow).filter((producer) => available.has(producer.id))
+ .map((producer) => recordsOnly ? { ...producer, outputs: producer.outputs.filter(isRecordsFlowOutput) } : producer)
+ .filter((producer) => !recordsOnly || producer.outputs.length > 0);
+ const loops = allowLoopItems && !recordsOnly ? enclosingFlowLoops(workflow, nodeId) : [];
const update = (index: number, binding: WorkflowFlowBinding) =>
onChange(bindings.map((current, position) => position === index ? binding : current));
const add = () => {
@@ -65,6 +70,7 @@ export function WorkflowFlowInputs({
{label}
Only these named final outputs are consumed. A skipped producer never falls back to another task.
+ {recordsOnly ? ' File export requires exactly one required records output. Partial output still needs this binding’s explicit acceptance and an eligible producer.' : ''}
{bindings.map((binding, index) => {
const source = binding.source;
@@ -85,7 +91,7 @@ export function WorkflowFlowInputs({
: { ...flowBinding(binding.name, producers[0]?.id ?? '', producers[0]?.outputs[0]?.name ?? ''),
expected_kind: producers[0]?.outputs[0]?.kind ?? 'any' })}>
Saved node output
- Current loop item, key and index
+ Current loop item, key and index
: null}
{source.kind === 'node_output' ? <>
@@ -100,7 +106,7 @@ export function WorkflowFlowInputs({
expected_kind: selected.outputs[0]?.kind ?? 'any',
});
}}>
- {!producer ? Unavailable: {source.node_id || 'choose a producer'} : null}
+ {!producer ? Unavailable: {source.node_id || 'choose a producer'} : null}
{producers.map((item) => {item.label} )}
@@ -115,7 +121,7 @@ export function WorkflowFlowInputs({
});
}}>
{!producer?.outputs.some((item) => item.name === source.output) ? (
- Unavailable: {source.output || 'choose an output'}
+ Unavailable: {source.output || 'choose an output'}
) : null}
{producer?.outputs.map((output) => {output.name} )}
@@ -136,7 +142,8 @@ export function WorkflowFlowInputs({
update(index, { ...binding, expected_kind: event.target.value as WorkflowOutputKind })}>
- {FLOW_OUTPUT_KINDS.map((kind) => {kind.replaceAll('_', ' ')} )}
+ {FLOW_OUTPUT_KINDS.map((kind) => {kind.replaceAll('_', ' ')} )}
@@ -158,11 +165,15 @@ export function WorkflowFlowInputs({
);
})}
- = 100} onClick={add}
+ = (recordsOnly ? 1 : 100)} onClick={add}
aria-label={`Add ${label.toLowerCase()} input`}>
Add input
- {!producers.length && !loops.length ? Add a reachable producer before this node to bind its output.
: null}
+ {!producers.length && !loops.length ?
+ {recordsOnly
+ ? 'No reachable saved records output is available. Declare records on an earlier task, Collect, or explicit join; text, scalar JSON, and document results are not file-export sources.'
+ : 'Add a reachable producer before this node to bind its output.'}
+
: null}
);
}
diff --git a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx
index b8ee05073..d6b1bec43 100644
--- a/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx
+++ b/application/v2_ui/src/components/workflows/WorkflowEditorDialog.tsx
@@ -29,10 +29,12 @@ import {
newWorkflowDefinition,
normalizeWorkflowDefinition,
isWorkflowPublicationCompletionPolicy,
+ isWorkflowPublicationSourceKind,
preservedWorkflowFieldLabels,
safeWorkflowAlias,
sameWorkflowDefinition,
saveWorkflowDefinition,
+ savedOutputPublicationFormats,
workflowErrorMessage,
workflowInputProcessingErrors,
workflowForSave,
@@ -60,6 +62,7 @@ import {
type WorkflowReferenceInput,
type WorkflowPublication,
} from '../../lib/workflowEditor';
+import { isRecord } from '../../lib/workspaceAuthoring';
const inputClass = 'w-full rounded-lg border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1 placeholder:text-text-3 focus:border-accent focus:outline-none';
const textareaClass = `${inputClass} min-h-24`;
@@ -1125,6 +1128,7 @@ function TaskCard({
{structuredNode ? (
onChange({ ...task, inputs })} />
) : }
{structuredNode && Boolean(options.supported_input_processing_modes?.length) || task.input_processing !== undefined ? 0;
+ const savedOutput = publication?.source_kind === 'saved_output';
const update = (value: WorkflowPublication) => onChange({ ...task, publication: value });
+ if (publication !== undefined && (!isRecord(publication) ||
+ Object.hasOwn(publication, 'source_kind') && !isWorkflowPublicationSourceKind(publication.source_kind) ||
+ (savedOutput ? publication.artifact_format !== 'json' : !['md', 'csv', 'json'].includes(publication.artifact_format)))) {
+ return The saved publication source or format is unsupported. Its original configuration is preserved and read-only.
;
+ }
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: {
@@ -1221,15 +1236,53 @@ function TaskPublicationFields({ task, options, durableExecution, onChange }: {
}} />
{publication ? (
- Publication destination
+ Publication source and destination
+
+ Publication source
+ {
+ const sourceKind = event.target.value;
+ if (!isWorkflowPublicationSourceKind(sourceKind) ||
+ sourceKind === 'saved_output' && !savedOutputAvailable) return;
+ update({
+ ...publication, source_kind: sourceKind,
+ artifact_format: sourceKind === 'saved_output' ? 'json' : publication.artifact_format,
+ });
+ }}>
+ Existing Analyze file
+ Saved workflow output
+
+
+ {savedOutput
+ ? 'Create a file export of every saved record object, including nested values and provenance, without rerunning analysis. This is not a qualitative report or a task-data handoff.'
+ : 'Publish the existing file from one explicitly bound native Analyze result.'}
+ {!savedFormats.length
+ ? ' This server does not advertise saved workflow output publication. Existing Analyze files remain available.'
+ : !durableExecution ? ' Saved workflow output requires durable execution.' : ''}
+
+
- Existing artifact format
+ {savedOutput ? 'File export format' : 'Existing artifact format'}
update({
- ...publication, artifact_format: event.target.value as WorkflowPublication['artifact_format'],
- })}>
- Markdown CSV JSON
+ value={publication.artifact_format} onChange={(event) => {
+ const format = event.target.value;
+ if (!['md', 'csv', 'json'].includes(format) ||
+ savedOutput && !savedFormats.some((supported) => supported === format)) return;
+ update({ ...publication, artifact_format: format as WorkflowPublication['artifact_format'] });
+ }}>
+ Markdown
+ CSV
+ {savedOutput ? savedFormats.map((format) => (
+ JSON - exact saved records
+ )) : JSON }
+ {savedOutput && !savedFormats.length ? (
+ JSON - exact saved records (not advertised)
+ ) : null}
+ {savedOutput ?
+ Only JSON exact saved records is available for this source. No format fallback or content reconstruction.
+ : null}
Destination scope
@@ -1290,7 +1343,10 @@ function TaskPublicationFields({ task, options, durableExecution, onChange }: {
- Bind exactly one earlier native Analyze output below. The destination is explicit, not your active workspace.
+ {savedOutput
+ ? 'Bind exactly one required records output from an earlier task, Collect, or explicit join below. '
+ : '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/lib/workflowEditor.ts b/application/v2_ui/src/lib/workflowEditor.ts
index 0d6b5ecf2..0cb7b316d 100644
--- a/application/v2_ui/src/lib/workflowEditor.ts
+++ b/application/v2_ui/src/lib/workflowEditor.ts
@@ -72,6 +72,7 @@ export interface WorkflowEditorOptions {
supported_binding_sources?: string[];
supported_input_processing_modes?: string[];
supported_publication_completion_policies?: string[];
+ publication_source_capabilities?: WorkflowPublicationSourceCapability[];
flow_limits?: {
max_nodes: number;
max_depth: number;
@@ -127,7 +128,16 @@ export interface WorkflowTaskApproval {
message?: string;
}
+export type WorkflowPublicationSourceKind = 'native_analysis' | 'saved_output';
+
+export interface WorkflowPublicationSourceCapability {
+ source_kind: string;
+ output_kinds: string[];
+ artifact_formats: string[];
+}
+
export interface WorkflowPublication {
+ source_kind?: WorkflowPublicationSourceKind;
artifact_format: 'md' | 'csv' | 'json';
workspace_scope: 'personal' | 'group' | 'public';
group_id?: string;
@@ -135,6 +145,17 @@ export interface WorkflowPublication {
completion_policy?: WorkflowPublicationCompletionPolicy;
}
+export function isWorkflowPublicationSourceKind(value: unknown): value is WorkflowPublicationSourceKind {
+ return value === 'native_analysis' || value === 'saved_output';
+}
+
+export function savedOutputPublicationFormats(options: WorkflowEditorOptions): 'json'[] {
+ const capability = options.publication_source_capabilities?.find((item) => item.source_kind === 'saved_output');
+ return capability?.output_kinds.includes('records')
+ ? [...new Set(capability.artifact_formats.filter((format): format is 'json' => format === 'json'))]
+ : [];
+}
+
export const WORKFLOW_PUBLICATION_COMPLETION_LABELS = {
submitted: 'Submitted',
approved: 'Approved',
@@ -1316,6 +1337,16 @@ export async function fetchWorkflowEditorOptions(
.some((runner) => !isRecord(runner) || runner.loop_eligible !== undefined && typeof runner.loop_eligible !== 'boolean')) {
throw new Error('The workflow editor returned invalid loop capabilities or limits.');
}
+ const publicationSources = response.publication_source_capabilities;
+ if (publicationSources !== undefined && (
+ !Array.isArray(publicationSources) || publicationSources.some((capability) =>
+ !isRecord(capability) || typeof capability.source_kind !== 'string' || !capability.source_kind.trim() ||
+ !Array.isArray(capability.output_kinds) || capability.output_kinds.some((kind) => typeof kind !== 'string') ||
+ !Array.isArray(capability.artifact_formats) || capability.artifact_formats.some((format) => typeof format !== 'string')) ||
+ new Set(publicationSources.map((capability) => capability.source_kind)).size !== publicationSources.length
+ )) {
+ throw new Error('The workflow editor returned invalid publication source capabilities.');
+ }
return response;
}
diff --git a/application/v2_ui/src/lib/workflowFlow.ts b/application/v2_ui/src/lib/workflowFlow.ts
index 0acc2e0cb..49082073c 100644
--- a/application/v2_ui/src/lib/workflowFlow.ts
+++ b/application/v2_ui/src/lib/workflowFlow.ts
@@ -286,6 +286,10 @@ export interface FlowProducer {
outputs: { name: string; kind: WorkflowOutputKind; kinds?: WorkflowOutputKind[]; required: boolean; schema?: Record }[];
}
+export function isRecordsFlowOutput(output: FlowProducer['outputs'][number]): boolean {
+ return output.kind === 'records' && (output.kinds ?? [output.kind]).every((kind) => kind === 'records');
+}
+
export function flowProducers(workflow: WorkflowDefinition): FlowProducer[] {
if (!isFlowRegion(workflow.flow)) return [];
const tasks = new Map(workflow.tasks.map((task) => [task.id, task]));
@@ -569,7 +573,30 @@ export function flowUnsupportedReason(workflow: WorkflowDefinition, options?: Wo
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;
+ if (publication === undefined) continue;
+ if (!isRecord(publication) || Object.keys(publication).some((key) => ![
+ 'source_kind', 'artifact_format', 'workspace_scope', 'group_id', 'public_workspace_id', 'completion_policy',
+ ].includes(key))) {
+ return 'This publication contains unsupported fields. Its original configuration is preserved and read-only.';
+ }
+ if (Object.hasOwn(publication, 'source_kind') &&
+ publication.source_kind !== 'native_analysis' && publication.source_kind !== 'saved_output') {
+ return 'This publication contains an unsupported source kind. Its original configuration is preserved and read-only.';
+ }
+ const savedOutput = publication.source_kind === 'saved_output';
+ if (savedOutput && (workflow.definition_version !== 3 || workflow.durable_execution !== true)) {
+ return 'Saved workflow output publication requires a durable definition-v3 workflow. The saved definition is preserved and read-only.';
+ }
+ if (savedOutput ? publication.artifact_format !== 'json'
+ : !['md', 'csv', 'json'].includes(publication.artifact_format)) {
+ return 'This publication contains an unsupported source/format combination. Its original configuration is preserved and read-only.';
+ }
+ if (savedOutput && options && !options.publication_source_capabilities?.some((capability) =>
+ capability.source_kind === 'saved_output' && capability.output_kinds.includes('records') &&
+ capability.artifact_formats.includes('json'))) {
+ return 'This server does not support the saved workflow output publication source/format. Its original configuration is preserved and read-only.';
+ }
+ if (!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.';
@@ -876,7 +903,19 @@ export function analyzeWorkflowFlow(workflow: WorkflowDefinition): WorkflowFlowA
}
}
if (task.publication) {
- if (bindings.length !== 1) errors.push(`${task.name} publication requires exactly one explicit native Analyze input.`);
+ const savedOutput = task.publication.source_kind === 'saved_output';
+ if (savedOutput) {
+ const binding = bindings[0];
+ const source = binding?.source;
+ const output = source?.kind === 'node_output'
+ ? producers.get(source.node_id)?.outputs.find((item) => item.name === source.output) : undefined;
+ if (bindings.length !== 1 || !binding.required || binding.expected_kind !== 'records' ||
+ source?.kind !== 'node_output' || !output || !isRecordsFlowOutput(output)) {
+ errors.push(`${task.name} publication requires exactly one required saved records node output from a task, Collect, or explicit join. Text, scalar JSON, document results, and current loop items cannot be exported.`);
+ }
+ } else if (bindings.length !== 1) {
+ errors.push(`${task.name} publication requires exactly one explicit native Analyze input.`);
+ }
if (!['md', 'csv', 'json'].includes(task.publication.artifact_format) ||
!['personal', 'group', 'public'].includes(task.publication.workspace_scope)) {
errors.push(`${task.name} publication needs a supported format and explicit destination.`);
diff --git a/docs/explanation/features/CHAT_ORCHESTRATION.md b/docs/explanation/features/CHAT_ORCHESTRATION.md
index 9727bf478..e0f697fac 100644
--- a/docs/explanation/features/CHAT_ORCHESTRATION.md
+++ b/docs/explanation/features/CHAT_ORCHESTRATION.md
@@ -1,6 +1,6 @@
# Chat Orchestration
-**Version: 0.261.105** (tracked in `application/single_app/config.py`)
+**Version: 0.261.119** (tracked in `application/single_app/config.py`)
**Implemented in version: 0.261.086**
**Knowledge phase added in version: 0.261.089**
@@ -466,6 +466,22 @@ pipeline all apply unchanged. Alongside it, a run record is written to the
recorded on the assistant message so reopening a conversation shows what produced the
answer.
+The registry reserves a future **knowledge -> reasoning -> output** progression,
+but as of **0.261.119** no capability is assigned to the output phase. The
+existing `respond` adapter's saved-Analyze formatting is not a general output
+workflow. A future output capability should use the same
+[Generated File Export Framework](GENERATED_FILE_EXPORT_FRAMEWORK.md) as chat
+and workflows: an authorized source adapter, an explicit renderer, the existing
+private artifact transport and optional existing workspace publication.
+Knowledge and reasoning supply durable data or approved content; rendering
+does not repeat that work or turn prose into engine state.
+
+Exact JSON for saved workflow records is the first new source mapping, not
+automatic orchestration output support. Generic CSV, Markdown, Word/DOCX, PDF
+and PowerPoint/PPTX mappings remain future extensions of that shared framework;
+existing XML/native formats are unchanged. This slice adds no output scheduling,
+workspace-placement or delivery capability.
+
## API
Planning and execution are deliberately separate requests. The plan is durable between
diff --git a/docs/explanation/features/GENERATED_FILE_EXPORT_FRAMEWORK.md b/docs/explanation/features/GENERATED_FILE_EXPORT_FRAMEWORK.md
index 096d9bec2..2890b316f 100644
--- a/docs/explanation/features/GENERATED_FILE_EXPORT_FRAMEWORK.md
+++ b/docs/explanation/features/GENERATED_FILE_EXPORT_FRAMEWORK.md
@@ -2,17 +2,20 @@
Implemented in version: **0.250.072**
-Updated through version: **0.250.154**
+Updated through version: **0.261.119**
GitHub issue: [#1071](https://github.com/microsoft/simplechat/issues/1071)
Related config.py update: `VERSION = "0.250.072"`
+Saved-record source adapter implemented in version: **0.261.119**, tracked in
+`application\single_app\config.py`.
+
## Overview
-Generated file output is a first-class response capability. The framework accepts the completed assistant response and the successful structured function results produced during the same turn, selects a requested renderer, and publishes one authorized downloadable chat artifact.
+Generated file output is a shared representation capability. The existing response path accepts the completed assistant response and successful structured function results from the same turn, selects a requested renderer, and publishes an authorized downloadable chat artifact. Since **0.261.119**, an explicit typed source path also renders authorized saved workflow records without asking a model to recreate them.
-CSV, JSON, XML, Word (`.docx`), and PDF are separate renderer capabilities. They share source normalization, output intent detection, artifact metadata, authorization-safe publication, downloads, and workspace-promotion behavior.
+CSV, JSON, XML, Word (`.docx`), and PDF are renderer capabilities within this framework, not independent export systems. Producer-specific source adapters share renderer dispatch, artifact transport, authorized downloads, and existing workspace publication. Supported source/format combinations remain explicit: the new generic saved-record adapter supports exact JSON only.
## Purpose
@@ -20,9 +23,17 @@ Function results previously remained available as citations, while downloadable
The framework normalizes current-turn structured function results once and makes them available to every supported renderer. CSV remains the first durable renderer; DOCX and PDF provide immediate generated artifacts for supported response-sized outputs.
+Saved workflow data, a rendered file, and a published workspace document serve
+different purposes. Typed bindings pass durable data to later tasks without
+exporting or indexing it. A file is a representation of selected data; workspace
+publication is a separate, explicit destination operation. Rendering does not
+replace original records, gather missing evidence, or decide workflow control flow.
+
## Dependencies
- `functions_generated_file_exports.py` for output intent, structured function-result normalization, renderer dispatch, and artifact metadata
+- `functions_workflow_artifacts.py` and the existing authorized workflow record reader for the exact saved-record source adapter and durable materialization checkpoints
+- `functions_generated_artifact_sources.py` for shared source-authorization dispatch across publication, downloads, previews, history, and conversion
- `functions_assistant_table_exports.py` for CSV intent, table parsing, safe headers, and formula-injection protection
- `functions_simplechat_operations.py` for authorized generated chat-artifact upload, download, promotion, and rollback
- `functions_tabular_generated_exports.py` for durable CSV batching, checkpoints, cancellation, reauthorization, and publication
@@ -53,6 +64,76 @@ Only function results from the current completed response are considered. The ad
A valid assistant-rendered table takes precedence over function-result rows for CSV. For DOCX and PDF, the final assistant response is included alongside normalized function-result tables.
+### Explicit Saved-Record Source Contract
+
+The typed branch of the existing `build_generated_file_export` entry point
+accepts these shared contracts:
+
+| Contract | Responsibility |
+| --- | --- |
+| `GeneratedFileExportRequest` | Explicit `profile="exact_records_v1"` and `output_format="json"`; no natural-language format detection or unsupported-format fallback. |
+| `GeneratedRecordExportSource` | A records source with its exact `record_count`, `iter_records()` and authorization/integrity `recheck()`. |
+| `GeneratedFileExportStream` | A managed seekable file stream with format, media type, byte size, SHA-256, record count and profile; closing it releases temporary storage. |
+
+The caller supplies `max_output_bytes` and a cancellation/lease `check`
+callback. The workflow adapter wraps the existing authorized reader for an
+exact committed task, Collect, or explicit join output. It does not make a
+Collect node pretend to be an Analyze task.
+
+The output is one JSON array of **every selected saved record object**, in
+reader order, including nested values and retained provenance. Unlike native
+Analyze formatting, it does not project just `record["values"]`. Unlike the
+current-turn action adapter, it does not infer envelopes, choose preview rows,
+filter arbitrary saved fields, or reconstruct data from a summary. Existing
+action-result sanitization remains unchanged.
+
+Encoding uses sorted object keys, compact separators, ASCII escaping and
+finite JSON values (`allow_nan=False`), without `default=str`, a BOM or generated
+commentary. Null, false, zero, nested arrays/objects, empty collections, long
+strings and repeated equal records are retained. This preserves JSON values,
+not the lexical formatting of an original uploaded file.
+
+Serialization reads the complete paged source into quota-bounded temporary
+storage without building a whole-collection list or string. SHA-256 and size
+are calculated from actual encoded bytes, including delimiters and escaping.
+A count mismatch, unsupported value, lost authority, cancellation or size
+overflow fails instead of exposing a truncated file. Accepted partial sources
+require explicit producer and consumer permission and remain visibly partial;
+declared uniqueness violations remain invalid rather than being deduplicated.
+
+See [Saved workflow output publication](WORKFLOW_SAVED_OUTPUT_PUBLICATION.md)
+for eligibility, source identity, recovery and destination behavior.
+
+### Shared Format Roadmap
+
+Existing renderers do not imply that every source can use every format.
+M4C-2 adds one exact saved-record representation; the other generic mappings
+below are **future extensions of this same framework**, not implemented
+workflow exporters.
+
+| Format | Existing support | Generic saved records in M4C-2 | Intended shared-framework extension |
+| --- | --- | --- | --- |
+| JSON | Generated-payload and native saved-Analyze paths. | Exact streamed array of selected saved record objects. | Additional explicitly typed source representations. |
+| CSV | Response/action tables and native tabular paths. | Not enabled. | Explicit columns and row unit, nested-value/null rules, formula safety and coverage semantics. |
+| Markdown | Native saved-Analyze/report and message export paths. | Not enabled. | Declared report/content mapping; prose must not become engine state or replace original data. |
+| Word/DOCX | Generated-response, native Analyze and message document renderers. | Not enabled. | Shared document layout/content mapping, large-output bounds and consistent transport. |
+| PDF | Generated-response, native Analyze and message document renderers. | Not enabled. | Shared document layout/content mapping with explicit renderer limits. |
+| PowerPoint/PPTX | Existing message/conversation presentation export, outside the generated-file dispatcher. | Not enabled. | A declared slide/content mapping through the shared output contract, not a parallel workflow exporter. |
+| XML | Existing shared generated-payload and native Analyze paths. | Not enabled. | Explicit schema/field mapping if generic saved-source support is added. |
+
+Human-readable layouts are projections, not a promise that every record shape
+can be represented losslessly in every format. Original saved records remain
+durable. Existing XML and native formats keep their behavior; this slice does
+not migrate the separate presentation exporter or enable its use for arbitrary
+saved records.
+
+The same boundary is intended for future orchestration:
+**authorized source adapter -> explicit shared renderer -> existing private
+artifact transport -> optional existing workspace publication**. Knowledge and
+reasoning supply data or approved content; an output phase would render it.
+No orchestration output capability is added here. See
+[Chat Orchestration](CHAT_ORCHESTRATION.md#outputs).
+
### Response Paths
The same finalizer is invoked after:
@@ -64,17 +145,32 @@ The same finalizer is invoked after:
- direct-model and agent workflows
- source-free model responses
-Each path supplies the final assistant content plus its current-turn function citations. The framework does not read arbitrary historical citations or externally supplied action identifiers.
+Each response path supplies the final assistant content plus its current-turn function citations. The framework does not read arbitrary historical citations or externally supplied action identifiers. Explicit saved-output workflow publication instead supplies the typed source and request above; its behavior does not depend on model phrasing.
### Artifact Publication
-The existing generated chat-artifact uploader remains the sole publication mechanism. It validates conversation ownership, allowed output extension, content size, and artifact metadata before creating a blob-backed file message.
+The existing generated chat-artifact uploader remains the shared file transport. It validates conversation ownership, allowed output extension, content size, and artifact metadata before creating a blob-backed file message. Workspace copies use the existing publication service and its destination receipt ledger.
Generated artifacts retain their format, capability, summary, preview metadata, and source provenance. The existing authorized download and workspace-promotion routes work without a new browser transport or external runtime asset.
Completed CSV, JSON, and XML file-export cards omit inline payloads and supporting diagnostics. They show the generated filename and row count when available, followed by format-specific Download and View actions plus Add to Workspace. View renders only bounded artifact preview metadata in a modal; the full file is read only by Download.
-During streaming JSON/XML generation, the browser receives one server-authored status such as `Generating the XML file. It will appear here when ready.` The model payload is accumulated privately for validation and publication rather than rendered token by token. If artifact publication cannot complete, finalization falls back to the accumulated model response instead of leaving the temporary status in place.
+Saved-record files use existing `generated_tabular_outputs` cards with
+`capability="file_export"`, `source_kind="workflow_saved_output"` and
+`row_source="saved_records"`. Public metadata excludes raw source bindings and
+Blob URLs. The server-only `generated_artifact_source` binding is checked for
+publication, preview, history and conversion as well as download. The existing
+`/api/chat_artifacts/download` path verifies the complete saved-output file in
+temporary storage before sending response bytes.
+
+For this typed source, materialization uses a stable source/representation key,
+create-only Blob/file-message writes and immutable prepare/ready units in the
+existing workflow journal. Retries verify the same address and actual digest;
+uncommitted materializations are not readable. This is not a second publication
+ledger. Cosmos-backed and Blob-backed workflow results both use the existing
+private Blob transport for generated files.
+
+During response-path streaming JSON/XML generation, the browser receives one server-authored status such as `Generating the XML file. It will appear here when ready.` The model payload is accumulated privately for validation and publication rather than rendered token by token. If artifact publication cannot complete, finalization falls back to the accumulated model response instead of leaving the temporary status in place. This legacy response fallback does not apply to explicit saved-record exports.
Structured artifact intent is normalized once for Chat, document Analyze, and workflow output selection. Destination phrasing such as `put the PDF content into the XML`, `place these fields in an XML document`, or `write these records as JSON` selects the existing artifact generation path without requiring words such as `create`, `download`, `file`, or `populate`. Source-only mentions such as `summarize the selected XML` and explicitly negated generation requests do not select an output artifact.
@@ -89,8 +185,16 @@ Examples:
When an action returns structured data and the assistant summarizes it instead of reprinting a table, the requested generated file still receives the normalized rows. If a request is ambiguous only for CSV row granularity or columns, the assistant asks the existing single conversation clarification before finalization.
+For a version-3 durable workflow, explicitly choose **Saved workflow output**
+as the publication source and bind one required records output. **JSON - exact
+saved records** produces the downloadable file and submits it to the task's
+chosen destination. This is not a new download-only task mode. Omitting
+`publication.source_kind` retains existing native Analyze publication.
+
## Testing and Validation
+- M4C-2 regression targets are `functional_tests\test_generated_file_saved_record_exports.py` for exact encoding/counts/quotas, `functional_tests\test_workflow_saved_output_artifacts.py` for source binding and materialization recovery, and `functional_tests\test_workflow_collect_publication.py` for the shared renderer-to-publication path. See the [saved-output validation commands](WORKFLOW_SAVED_OUTPUT_PUBLICATION.md#testing-and-validation); these describe coverage, not a completed test-run result.
+- `ui_tests\test_v2_workflow_saved_output_publication.py` targets source choice, typed bindings, format restrictions and compatibility through the local V2 fixture.
- `functional_tests/test_assistant_table_csv_artifact.py` covers CSV, DOCX, PDF, structured function-result normalization, sensitive-field exclusion, multi-action provenance, assistant-table precedence, and tabular-plugin exclusion.
- `functional_tests/test_generated_json_xml_exports.py` covers JSON/XML parsing, hardened XML handling, completed file-export metadata, and format-specific View actions.
- `ui_tests/test_chat_generated_tabular_output_card.py` covers concise completed cards and bounded CSV, JSON, and XML preview modals.
@@ -106,3 +210,5 @@ When an action returns structured data and the assistant summarizes it instead o
- DOCX and PDF render immediately for response-sized content; durable long-form DOCX work is tracked separately in [#1072](https://github.com/microsoft/simplechat/issues/1072).
- The framework deliberately does not route tabular-plugin rows around source coverage, authorization, or source-version checks.
- Unsupported, failed, unresolved, canceled, or partial source states remain visible through their existing evidence and export contracts; the framework does not fabricate missing rows.
+- Exact saved-record serialization and its shared upload, download and publication handoff avoid whole-file buffers. Native destination ingestion still uses the existing worker and format-specific indexing; this does not assert that the downstream native indexer itself has constant memory use.
+- JSON file availability, destination approval and index readiness are separate facts. An empty array can be a valid export without being searchable content.
diff --git a/docs/explanation/features/WORKFLOW_FOR_EACH_COLLECT.md b/docs/explanation/features/WORKFLOW_FOR_EACH_COLLECT.md
index a0d80d2f2..a31959cc4 100644
--- a/docs/explanation/features/WORKFLOW_FOR_EACH_COLLECT.md
+++ b/docs/explanation/features/WORKFLOW_FOR_EACH_COLLECT.md
@@ -2,6 +2,8 @@
Implemented in version: **0.261.117**
+Updated in version: **0.261.119**.
+
Application version source: `application\single_app\config.py`.
## Purpose and dependencies
@@ -232,11 +234,28 @@ Key tests include `test_workflow_for_each_execution.py`,
`route_tests\test_workflow_loop_policy.py`, and
`ui_tests\test_v2_workflow_loops.py`.
-Repeat until, parallel iteration, hosted-agent loops, generic aggregate
-publication, publication/index-readiness continuation, cumulative spending
-caps, and the visual Flow editor are not included. Original native Analyze
-artifacts still use the existing publication service and destination ledger;
-Collect is not relabeled as native Analyze to bypass that boundary.
+Since **0.261.119**, a later Publish task can explicitly select **Saved workflow
+output** and bind Collect's eligible `records` output, directly or through an
+explicit join. The shared Generated File Export Framework writes every selected
+saved record object as exact JSON and submits the file to the chosen workspace.
+It preserves order, duplicates, nested values and retained provenance without
+rerunning the loop. This does not enable generic file export of
+`document_results` bundles. Partial publication requires explicit Collect and
+publishing-input acceptance; invalid uniqueness remains invalid.
+
+The [publication completion policies](WORKFLOW_PUBLICATION_COMPLETION.md)
+introduced in **0.261.118** also apply to this saved-record file. A downloadable
+JSON file, including a valid empty array, is not proof of destination approval
+or index readiness. The existing publication service and sole destination
+ledger remain in use; Collect is never relabeled as native Analyze. See
+[Saved workflow output publication](WORKFLOW_SAVED_OUTPUT_PUBLICATION.md) for
+the source contract, authorization, recovery and validation commands.
+
+Generic CSV, Markdown, Word/DOCX, PDF, PowerPoint/PPTX and XML mappings remain
+future extensions of that same shared framework; existing native formats keep
+their behavior. Repeat until, M5 read-only Flow and accessible visual authoring
+remain separate future slices. Parallel iteration and hosted-agent loops remain
+unsupported; cumulative run-token/spending caps remain deferred.
Validation uses fictional data and isolated services. It is not evidence of a
live deployment or permission change.
diff --git a/docs/explanation/features/WORKFLOW_PUBLICATION_COMPLETION.md b/docs/explanation/features/WORKFLOW_PUBLICATION_COMPLETION.md
index e3344638b..a7564e421 100644
--- a/docs/explanation/features/WORKFLOW_PUBLICATION_COMPLETION.md
+++ b/docs/explanation/features/WORKFLOW_PUBLICATION_COMPLETION.md
@@ -2,30 +2,39 @@
Implemented in version: **0.261.118**.
+Saved-output integration implemented in version: **0.261.119**.
+
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.
+A workflow can submit an existing Analyze artifact, or explicitly render and
+submit saved records, without waiting for the destination to be searchable.
+It can instead 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.
+and group workspaces. Both source paths use the existing publication receipt
+ledger, workspace approval routes, native document processing, screening, and
+workflow runner. It adds no scheduler, Cosmos container, administrator setting,
+or model call.
+
+Since **0.261.119**, **Saved workflow output** creates exact JSON through the
+shared Generated File Export Framework before entering this same completion
+service. It requires one eligible, explicitly bound records output from a task,
+Collect, or explicit join. Generic records are never relabeled as native
+Analyze artifacts. See
+[Saved workflow output publication](WORKFLOW_SAVED_OUTPUT_PUBLICATION.md)
+for source eligibility, partial acceptance and file materialization.
## 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**.
+In the V2 List editor, enable **Publish a workflow file** and choose **Existing
+Analyze file** or **Saved workflow output**. Bind exactly one eligible output,
+choose a supported format and explicit destination, then select **Complete
+publication when**. Servers without the new source capability retain **Publish
+an existing analysis artifact** and native behavior.
| Level | When the workflow can continue | What remains outside the promise |
| --- | --- | --- |
@@ -67,11 +76,25 @@ 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.
+The example intentionally omits `source_kind`, preserving native Analyze
+publication. Generic records require `source_kind: "saved_output"`,
+`artifact_format: "json"` and one required `node_output` records binding.
+Source options are advertised separately in `publication_source_capabilities`;
+they do not change the meaning or omission behavior of completion policies.
+
+For native artifacts, 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.
+
+Native receipt identities are unchanged. For saved-output files, the same
+ledger binds the validated generic source instead of a native
+`analysis_producer`. The version-3 request remains
+`workflow-publication:v3:{publishing_execution_id}:{producer_execution_id}:{producer_attempt}`.
+File materialization has its own deterministic source/representation key,
+independent of publishing attempt and destination; its existing journal units
+do not replace destination receipts.
A private `artifact_publication` continuation points back to this receipt.
It is persisted with the existing task checkpoint, not in a second job
@@ -134,6 +157,10 @@ 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.
+A valid exact JSON file, including an empty array, therefore does not prove
+index readiness. Saved-output publication uses the same native worker and
+original-content evidence, not a new saved-record indexer.
+
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.
@@ -173,6 +200,8 @@ when a workflow or its private run results are deleted.
| Component | Responsibility |
| --- | --- |
+| `functions_workflow_artifacts.py` and `functions_generated_file_exports.py` | Authorized saved-record materialization before entering the existing publication service. |
+| `functions_generated_artifact_sources.py` | Source-specific authorization without weakening native Analyze provenance. |
| `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. |
@@ -191,3 +220,11 @@ 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.
+
+Saved-output integration is targeted by
+`functional_tests\test_workflow_collect_publication.py`,
+`functional_tests\test_workflow_saved_output_artifacts.py` and
+`ui_tests\test_v2_workflow_saved_output_publication.py`, alongside those native
+regressions. Coverage includes both result backends, exact source retry
+identity and unchanged completion levels; see the
+[validation commands](WORKFLOW_SAVED_OUTPUT_PUBLICATION.md#testing-and-validation).
diff --git a/docs/explanation/features/WORKFLOW_SAVED_OUTPUT_PUBLICATION.md b/docs/explanation/features/WORKFLOW_SAVED_OUTPUT_PUBLICATION.md
new file mode 100644
index 000000000..72b8ff2e7
--- /dev/null
+++ b/docs/explanation/features/WORKFLOW_SAVED_OUTPUT_PUBLICATION.md
@@ -0,0 +1,340 @@
+# Saved workflow output publication
+
+Implemented in version: **0.261.119**.
+
+Related application version update: `VERSION = "0.261.119"` in
+`application\single_app\config.py`.
+
+## Overview and purpose
+
+A version-3 durable workflow can explicitly select an eligible saved records
+output, render every selected record as exact JSON, and submit that file to a
+chosen workspace. The producer can be a real task, Collect, or explicit join.
+This makes a collected dataset downloadable and publishable without rerunning
+Analyze, asking a model to reconstruct rows, or disguising Collect as a native
+Analyze task.
+
+This M4C-2 slice uses the existing
+[Generated File Export Framework](GENERATED_FILE_EXPORT_FRAMEWORK.md).
+There is no workflow-only renderer or second publication service.
+
+| Representation | Purpose |
+| --- | --- |
+| Saved task or engine output | Durable data consumed through explicit typed bindings; no workspace upload or indexing is needed for the next task. |
+| Generated JSON file | A byte-verified representation of the selected saved records, accessible through existing private generated-file downloads. |
+| Published workspace document | An explicitly requested destination copy, with its own permissions, approval, processing and readiness. |
+
+The **Saved workflow output** Publish task creates the downloadable file **and
+submits it to its configured destination**. It is not a new download-only task
+mode. Merely saving or validating a result does not publish it.
+
+## Dependencies and compatibility
+
+- Personal or group workflows must use definition version **3** and durable
+ execution. Existing workflow enablement and role requirements still apply.
+- The selected records must have an eligible saved result and an exact
+ committed attempt in the existing schema-2 journal.
+- Workflow results can use either Cosmos or Blob storage. The generated file
+ itself still requires the existing configured **private Blob artifact
+ transport**; Cosmos result storage is not a file-storage fallback.
+- The existing workflow runner, result reader, generated-file transport,
+ publication receipt ledger, destination approval and native ingestion are
+ reused. No administrator switch, scheduler, container or definition version
+ is added.
+
+Omitting `publication.source_kind` retains native Analyze publication and stays
+omitted on a definition round-trip. Explicit `native_analysis` selects the same
+native path, with its existing formats and provenance requirements. Nothing
+converts legacy tasks into generic exports.
+
+The server advertises source-specific availability through optional
+`publication_source_capabilities` entries containing `source_kind`,
+`output_kinds` and `artifact_formats`. Saved-output formats come from shared
+export-profile support. An older server does not implicitly gain this option;
+unsupported saved source/format configurations remain intact and read-only.
+
+## Definition and source eligibility
+
+The publication object gains one optional discriminator, `source_kind`, whose
+values are `native_analysis` and `saved_output`. For the latter, the only
+supported `artifact_format` is `json`. The task must bind exactly one required
+`node_output` input with `expected_kind: "records"`:
+
+```json
+{
+ "publication": {
+ "source_kind": "saved_output",
+ "artifact_format": "json",
+ "workspace_scope": "group",
+ "group_id": "explicit-destination-id",
+ "completion_policy": "indexed_ready"
+ },
+ "inputs": [
+ {
+ "name": "deliverable",
+ "source": {
+ "kind": "node_output",
+ "node_id": "collect-findings",
+ "output": "records",
+ "scope": "current"
+ },
+ "required": true,
+ "expected_kind": "records",
+ "allow_partial": false
+ }
+ ]
+}
+```
+
+Here `collect-findings` and `records` must identify a real node and its selected
+records output in the frozen definition. A task's authoritative records output
+is also eligible. An explicit join retains its selected-producer receipt and
+branch lineage; it is not resolved by looking for a recent task ID.
+
+The adapter rejects loop-item inputs, optional missing inputs, diagnostics,
+preview rows, text, scalar or untyped JSON, and `document_results` bundles.
+Nested objects and arrays **inside supported record objects** remain supported.
+Invalid, failed, pending or unreadable sources cannot become valid files by
+changing the format.
+
+Accepted partial output requires both the existing producer/Collect partial
+contract and `allow_partial: true` on the publishing input. Validation and
+coverage remain visibly partial; missing work is not relabeled complete.
+Duplicates and record order are preserved. A declared uniqueness-contract
+violation remains invalid: export never deduplicates it into a passing result.
+
+`source_kind` describes the source, not the destination. Personal, group and
+public destination fields keep their existing meaning. The selected workspace
+is explicit and does not follow the user's active workspace.
+
+## Shared rendering and exact JSON
+
+`WorkflowRecordExportSource` in `functions_workflow_artifacts.py` wraps the
+existing authorized `open_workflow_record_input` reader. It verifies the real
+node/execution/path/attempt, committed `result_ref`, selected `output_name` and
+`output_ref` before invoking the shared renderer:
+
+- `GeneratedRecordExportSource` supplies `kind="records"`, `record_count`,
+ `iter_records()` and `recheck()`.
+- `GeneratedFileExportRequest(profile="exact_records_v1", output_format="json")`
+ explicitly selects the representation at `build_generated_file_export`.
+- `GeneratedFileExportStream` owns the completed seekable stream and its media
+ type, format, profile, byte size, record count and content SHA-256. Closing it
+ releases temporary storage.
+
+The file is one JSON array containing every selected saved record object in
+reader order, including nested `values` and retained provenance. It does not
+strip the object down to `values`, flatten fields, use just a preview, infer an
+action-result envelope, or regenerate missing data from prose. Existing
+sanitization for the framework's legacy action-result adapter is unchanged.
+
+The `exact_records_v1` profile uses sorted object keys, compact separators,
+ASCII escaping and finite JSON values (`allow_nan=False`). It uses no
+`default=str`, generated commentary or BOM. Null, booleans, zero, long strings,
+Unicode, nested arrays/objects and repeated records retain their JSON values.
+An eligible empty collection produces `[]`. This is value preservation, not
+preservation of an original document's lexical JSON formatting.
+
+The complete paged collection is serialized into quota-bounded temporary
+storage without building a whole-collection list or string. SHA-256 and length
+cover the actual encoded bytes, including commas, brackets and string
+escaping. The written record count must equal the declared count. An exact
+byte-limit match succeeds; overflow fails rather than exposing a shortened
+file. Cancellation/lease and source checks run during the operation and again
+at completion.
+
+## Durable file identity and recovery
+
+The source/representation key includes workflow scope, frozen definition
+revision, exact producer identity, `result_ref`, `output_name`, `output_ref`,
+the authored partial policy, profile and format. It does not include the
+publishing task's mutable attempt, destination or current time.
+
+The same source representation therefore keeps:
+
+- Artifact idempotency key: `generated-export:v1:{export_key}`.
+- Filename: `workflow-output-{export_key}.json`.
+- The existing deterministic generated-artifact address.
+- SHA-256 of the completed file bytes, not of a preview or summary.
+
+Materialization persists an immutable descriptor through the existing result
+store. Existing root-node journal `unit` records
+`["generated-file-prepare", export_key]` and
+`["generated-file-ready", export_key]` bind preparation and commitment to the
+real root selectors. These units track file creation only; they are not a new
+destination ledger.
+
+Preparation fixes the source, address, digest, byte size and count before
+upload. The typed transport uses create-only Blob and file-message writes.
+A lost acknowledgement is reconciled by verifying the same address, source
+binding, actual bytes and digest, not by overwriting a different artifact.
+If recreation is needed, the same source must produce the prepared digest.
+No uncommitted file is readable or publishable before its ready checkpoint.
+
+The server-authored `generated_artifact_source` binding has
+`kind="workflow_saved_output"` and includes the exact source receipt, scope,
+partial policy, representation and materialization reference. It is separate
+from native `analysis_producer` metadata. Collect and join identities never gain
+fabricated task IDs or native Analyze flags.
+
+After commitment, the existing publication service submits the file. Its v3
+request identity remains:
+
+```text
+workflow-publication:v3:{publishing_execution_id}:{producer_execution_id}:{producer_attempt}
+```
+
+The existing sole artifact-message ledger retains destination effects, approval,
+notifications and reconciliation. Native receipt identities are unchanged;
+generic identities additionally bind the validated saved-output source.
+Different publication nodes or destinations may reuse a source artifact while
+retaining their own destination receipts.
+
+## Authorization and existing APIs
+
+Source receipts, digests and artifact locators are not permissions. Current
+workflow/run ownership, group membership/status, conversation access,
+contributing sources, frozen input/iteration membership and artifact lifecycle
+or approval are rechecked at sensitive boundaries. The run's frozen definition
+and exact committed attempt are authoritative, not the currently edited
+workflow or a latest-task lookup.
+
+`functions_generated_artifact_sources.py` dispatches native artifacts to their
+existing authorization and saved-output files to the workflow adapter. The
+shared check covers publication, downloads, previews, history and conversion.
+A missing or malformed typed binding fails closed instead of falling back to
+an unbound artifact.
+
+No new public endpoint is added. The existing `/api/chat_artifacts/download`
+route verifies the entire saved-output file into temporary storage before
+streaming response bytes, using the existing private/no-store and attachment
+behavior. Publication and approval use the existing stream-capable handoff.
+Native document processing still runs in the existing worker; these bounded
+file paths do not claim constant-memory behavior for the downstream indexer.
+
+Chat uses existing `generated_tabular_outputs` cards with
+`capability="file_export"`, `source_kind="workflow_saved_output"` and
+`row_source="saved_records"`. Cards and history expose authorized, allowlisted
+file metadata rather than raw bindings, internal locators or Blob URLs.
+They identify a generic file export, not a native Analyze result.
+
+Already-published copies remain governed by their independent destination
+permissions. Source revocation prevents further private-source reads or
+republication; it does not delete completed copies or replace their ACLs.
+
+## Implementation files
+
+Backend modules remain under `application\single_app\`:
+
+| Files | Responsibility |
+| --- | --- |
+| `functions_generated_file_exports.py` | Shared typed request, records-source protocol, exact renderer and managed stream. |
+| `functions_workflow_artifacts.py` | Workflow source authorization, representation identity and journal-backed materialization. |
+| `functions_generated_artifact_sources.py` | Native/generic source dispatch and safe history projection. |
+| `functions_personal_workflows.py`, `functions_workflow_definitions.py`, `functions_workflow_flow.py`, `functions_workflow_editor.py` | Publication normalization, compilation and server-advertised capabilities. |
+| `functions_workflow_runner.py` | Source-specific dispatch into the shared export and existing publication/completion path. |
+| `functions_simplechat_operations.py`, `functions_artifact_publication.py` | Existing private file transport, destination ledger, approval and stream-capable processing handoff. |
+| `route_enhanced_citations.py`, `route_backend_conversation_export.py` | Existing authorized artifact download and conversion boundaries. |
+
+In `application\v2_ui\src\`, `components\workflows\WorkflowEditorDialog.tsx`,
+`lib\workflowEditor.ts` and `lib\workflowFlow.ts` own the source controls,
+capability guards and typed binding validation. No new browser transport or
+external runtime asset is needed.
+
+## Usage
+
+1. Produce and validate a records output. For per-document work, use For each
+ and a real Collect node to retain each eligible saved record; a later task
+ binds to Collect rather than the last child to finish.
+2. In a later V2 List task, enable **Publish a workflow file**, choose
+ **Saved workflow output**, and bind the exact required records output.
+ **Existing Analyze file** remains the choice for a previously generated
+ native analysis artifact.
+3. Use **JSON - exact saved records** and choose the destination explicitly.
+ A group/public copy still requires the existing destination review.
+4. Choose the completion level for the downstream need. **Submitted** confirms
+ handoff, **Approved** confirms destination approval where required, and
+ **Indexed and ready** requires native original-content processing,
+ screening availability and the complete scoped index proof.
+5. Inspect the exact run/attempt and generated-file card. Download the file
+ through the existing authorized action; keep later data-processing tasks
+ bound to saved records rather than scraping the file card.
+
+For example, a document loop can Analyze each frozen document, Collect its
+records, optionally select those records through a branch join, and Publish
+the exact JSON. Rendering neither reruns the analysis nor indexes source
+results merely to pass them to the publisher.
+
+Personal **Approved** reports approval as `not_required` at confirmed
+submission. Queued or approved is not indexed-ready, and a valid empty array
+does not prove searchable content. An already-fulfilled completion snapshot
+stays immutable; current authorization is checked separately. Uncertain effects
+pause on the existing receipt, while permission failures remain distinct.
+Resume and continue-on-error cannot convert an unmet policy into success.
+See [Workflow publication completion](WORKFLOW_PUBLICATION_COMPLETION.md).
+
+New publication controls default to Submitted only when supported by the
+server. Omitted completion policies keep their prior behavior. Source choice
+does not silently add a policy or change existing native definitions.
+
+## Format direction in the same framework
+
+Only JSON is enabled for this generic saved-record source. Existing response,
+native Analyze and message exporters keep their supported combinations,
+including XML. Follow-on formats require explicit mappings in the **same
+Generated File Export Framework**, not separate workflow exporters:
+
+| Format | Generic saved-record status and intended mapping |
+| --- | --- |
+| JSON | Implemented as `exact_records_v1`; additional typed representations remain future work. |
+| CSV | Future: declared columns/row unit, nested-value and null rules, formula safety and coverage semantics. |
+| Markdown | Future: an explicit report/content projection that does not replace saved engine data. |
+| Word/DOCX | Future: shared document layout/content mapping with large-output bounds and the same artifact transport. |
+| PDF | Future: shared document layout/content mapping with explicit renderer limits. |
+| PowerPoint/PPTX | Future: a declared slide/content mapping. Existing message presentation export is not yet a generic saved-record renderer. |
+| XML | Future for this source: explicit schema/field mapping; existing native/generated XML support is unchanged. |
+
+See the [shared format matrix](GENERATED_FILE_EXPORT_FRAMEWORK.md#shared-format-roadmap)
+for existing paths. A future orchestration knowledge -> reasoning -> output
+phase should use this same source/renderer/transport boundary. This slice adds
+neither an orchestration output capability nor automatic report or slide
+generation. Human-readable projections must keep the original records durable.
+
+## Testing and validation
+
+The regression suites for this slice target the following contracts; this list
+does not report a completed test run:
+
+| Suite | Coverage to validate |
+| --- | --- |
+| `functional_tests\test_generated_file_saved_record_exports.py` | Deterministic complete JSON, nested values, duplicates, byte/count limits, unsupported values and shared-renderer compatibility. |
+| `functional_tests\test_workflow_saved_output_artifacts.py` | Exact attempt/source binding, both result stores, private transport, immutable preparation/recovery, digest checks and access revocation. |
+| `functional_tests\test_workflow_collect_publication.py` | Real frozen loop/native Analyze/Collect/join and non-Analyze records through the shared framework and existing publication/completion service. |
+| `ui_tests\test_v2_workflow_saved_output_publication.py` | Source choices, eligible record bindings, JSON-only behavior, save/reopen, omitted/unsupported compatibility and local V2 authoring. |
+
+The existing native loop fixture, M4C-1 completion suites, structured
+publication, generated-artifact authorization and download-byte tests remain
+regression boundaries. Run from the repository root with the repository's
+test dependencies and a locally built V2 bundle for browser fixtures:
+
+```powershell
+python -m pytest .\functional_tests\test_generated_file_saved_record_exports.py .\functional_tests\test_workflow_saved_output_artifacts.py .\functional_tests\test_workflow_collect_publication.py
+python -m pytest .\functional_tests\test_workflow_structured_publication.py .\functional_tests\test_workflow_publication_completion.py .\functional_tests\test_publication_native_processing.py .\functional_tests\test_chat_artifact_download_bytes.py
+python -m pytest .\ui_tests\test_v2_workflow_saved_output_publication.py .\ui_tests\test_v2_workflow_publication_completion.py
+```
+
+Fixtures use closed service doubles, fictional data and local UI assets. They
+do not publish documents to live workspaces or establish deployment acceptance.
+
+## Limits and stopping boundary
+
+- Existing record-tree, result/artifact size, model/context, admission, retry,
+ depth and elapsed-deadline limits remain enforced. No silent truncation or
+ unsafe context splitting is introduced.
+- Loop and saved-record report runners remain locally metered. For each
+ defaults to **500 actual selected items**; administrators can set **1-5,000**
+ for new runs only. A searchable corpus is not itself a loop selection.
+- The owner-deferred cumulative run-token/spend cap is not implemented here.
+- Repeat until, M5 read-only Flow and accessible visual authoring remain
+ separate future slices. This feature adds no automation of those steps, new
+ scheduler, promotion service or destination ledger.
diff --git a/docs/explanation/features/WORKFLOW_STRUCTURED_CONTROL_FLOW.md b/docs/explanation/features/WORKFLOW_STRUCTURED_CONTROL_FLOW.md
index 83f991d39..1afc81a25 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.118**.
+Updated in version: **0.261.119**.
Application version tracking: `application/single_app/config.py`.
@@ -19,8 +19,10 @@ a known later step can safely replace optional intermediate work.
This page describes the M4A foundation. Version **0.261.117** adds
[serial For each and exact Collect](WORKFLOW_FOR_EACH_COLLECT.md) to the same
-definition version and journal. Repeat until, generic aggregate publication,
-and the visual Flow editor remain separate. M4A itself did not admit loops.
+definition version and journal. Version **0.261.119** adds
+[saved-record JSON publication](WORKFLOW_SAVED_OUTPUT_PUBLICATION.md).
+Repeat until, M5 read-only Flow and accessible visual authoring remain separate
+future slices. M4A itself did not admit loops.
## Dependencies and compatibility
@@ -211,8 +213,27 @@ 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.
+another copy. Omitted policies retain the previous behavior.
+
+In **0.261.119**, explicitly choosing **Saved workflow output**
+(`publication.source_kind: "saved_output"`) renders one required `node_output`
+records binding from a real task, Collect, or explicit join as exact JSON
+through the shared Generated File Export Framework. The Publish task creates
+the downloadable file and submits it to the chosen destination using the same
+completion policies. It is not a download-only task or a native Analyze
+artifact. Omitting the source choice preserves existing native publication.
+
+Saved-record serialization rechecks current scope and the exact source attempt
+every 100 records. Reusing a materialized file still verifies its exact ready
+checkpoint. Generic destination approval rechecks current source and destination
+authority after the conditional decision write and before its external effect.
+
+Generic CSV, Markdown, Word/DOCX, PDF, PowerPoint/PPTX and XML mappings remain
+future extensions of the **same framework**, not separate workflow exporters.
+Existing native formats are unchanged. See the
+[shared format roadmap](GENERATED_FILE_EXPORT_FRAMEWORK.md#shared-format-roadmap)
+and [saved-output contract](WORKFLOW_SAVED_OUTPUT_PUBLICATION.md) for exact
+record preservation, partial acceptance and source eligibility.
## Regression coverage and boundaries
diff --git a/docs/guides/create-a-workflow.md b/docs/guides/create-a-workflow.md
index ade80d5f3..1724c375f 100644
--- a/docs/guides/create-a-workflow.md
+++ b/docs/guides/create-a-workflow.md
@@ -86,10 +86,10 @@ query, choose exhaustive metadata/keyword matches or an explicit **Best N**
relevance selection. A preview is advisory; the loop freezes its actual
membership when it starts and does not reselect documents on Resume.
-The default administrator ceiling is 500 items, and the editor shows the
-effective limit. This counts actual loop visits, not the searchable workspace.
-If the selection is too large, narrow it before running; no first-500 subset is
-silently substituted.
+The default administrator ceiling is 500 items, configurable from 1-5,000 for
+new runs only, and the editor shows the effective limit. This counts actual
+loop visits, not the searchable workspace. If the selection is too large,
+narrow it before running; no first-500 subset is silently substituted.
Bind the current item to body tasks and choose current-document Analyze when
appropriate. Keep shared criteria in shared references. Declare the body's
@@ -167,32 +167,78 @@ coverage, accepted findings, and validation.
## Publish an existing analysis artifact
-In a later task, select **Publish an existing analysis artifact**, choose an
-**Existing artifact format**, and select a **Publication destination**. A group
-or public destination also requires its **Destination workspace ID**. That
-destination is saved with the task; changing your active workspace later does
-not redirect the publication.
+In the V2 List editor, enable **Publish a workflow file** on a later task and
+choose **Existing Analyze file** as its **Publication source**. Servers without
+the new source options retain **Publish an existing analysis artifact**.
+Choose an **Existing artifact format** and **Destination scope**. A group or
+public destination also requires its **Destination workspace ID**. That
+destination is saved with the task; changing your active workspace later
+does not redirect the publication.
This task copies an existing artifact rather than calling a model to recreate
it. Ensure the analysis produced the selected format. Passing validation alone
does not publish anything: this explicit task or a manual workspace-save action
-is required. Partial or invalid results cannot be published as final outputs.
+is required. This native-artifact path does not publish partial or invalid
+results as final outputs. Existing definitions with no explicit publication
+source keep native Analyze behavior.
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.
+## Publish saved workflow records
+
+In **0.261.119**, a version-3 durable workflow can publish records from a real
+task, **Collect**, or an explicit **Join outputs** selection. Use this when you
+need the complete collected dataset as a file, rather than an explanation of
+the dataset or a copy of one native Analyze artifact.
+
+1. Produce an eligible records output. For example, Analyze each document in a
+ frozen For each selection, then Collect the records outside the loop.
+2. In a later task, enable **Publish a workflow file** and explicitly select
+ **Saved workflow output**. Bind exactly one required records output from
+ the chosen producer. Do not select the current loop item, diagnostics,
+ text, an arbitrary JSON value, or a per-document results bundle.
+3. Choose **JSON - exact saved records**, an explicit destination, and the
+ completion level described below.
+
+The JSON array contains every selected saved record object, including nested
+values and retained provenance, in the saved order. Repeated records stay
+repeated. It is not a preview or a model reconstruction; serialization preserves
+JSON values rather than an uploaded document's original formatting. The
+original records remain available to later tasks through their typed bindings.
+
+Partial coverage is usable only when both the producer/Collect policy and the
+publishing input explicitly accept it. It stays visibly partial. Invalid
+uniqueness, failed or pending results, unavailable sources, and files exceeding
+the configured limit fail rather than being silently repaired or truncated.
+
+This Publish task creates a downloadable file through the shared Generated
+File Export Framework **and submits it to the selected workspace**; it is not
+a new download-only mode. Generic CSV, Markdown, Word/DOCX, PDF, PowerPoint/PPTX
+and XML mappings are not enabled. Those are future extensions of the same
+framework; existing native formats keep their behavior.
+
+If the server does not advertise saved-output publication, the option is
+unavailable rather than silently falling back to native Analyze. Unsupported
+saved source/format configurations remain intact and read-only.
+
+## Choose when publication completes
+
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
+approval as not required. These same levels apply to Saved workflow output
+in **0.261.119**.
+
+New publication tasks default to Submitted when the server advertises support.
+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. A valid JSON download, including an empty array, does not
+prove that its destination has searchable content. See
[Workflow publication completion](../explanation/features/WORKFLOW_PUBLICATION_COMPLETION.md)
for readiness proof, screening and recovery limitations.
diff --git a/docs/guides/trigger-a-workflow.md b/docs/guides/trigger-a-workflow.md
index 8bbd74ba8..a2933db77 100644
--- a/docs/guides/trigger-a-workflow.md
+++ b/docs/guides/trigger-a-workflow.md
@@ -109,6 +109,30 @@ task retains its data rather than receiving a truncated substitute.
See [Serial For each and exact Collect](../explanation/features/WORKFLOW_FOR_EACH_COLLECT.md).
+## Inspect a saved-output publication
+
+In **0.261.119**, a task explicitly configured with **Saved workflow output**
+renders its selected saved records as JSON and submits that file to its chosen
+destination. Run inspection identifies the exact producer, output and attempt;
+a repeated task name or latest chat reply is not the source identity.
+
+Use the existing generated-file card to download the full JSON, not a preview
+of the first records. Record order, duplicates, nested values and retained
+provenance are preserved. Accepted partial output remains visibly partial;
+a file never supplies records that its producer did not save.
+
+Reloading or resuming retains the same source representation and publication
+receipt. It does not rerun Analyze or select a newer producer attempt merely
+to obtain a file. A downloadable file is not proof that the destination is
+approved or indexed-ready: inspect the separate completion observations.
+An empty JSON array may be a valid file without searchable content.
+
+Private downloads still require current conversation, workflow and source
+access. Already-published workspace copies follow their own destination
+permissions. See
+[Publish saved workflow records]({{ '/guides/create-a-workflow/' | relative_url }}#publish-saved-workflow-records)
+for source choices and requirements.
+
## Troubleshooting
### A publication is waiting
@@ -120,7 +144,7 @@ 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.
+does not rerun Analyze or create 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
diff --git a/functional_tests/test_analysis_artifact_publication.py b/functional_tests/test_analysis_artifact_publication.py
index 98e264763..4ebd4bb7c 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.118
+Version: 0.261.119
Implemented in: 0.261.109
Exercise real publication, normalization, and route bodies with Cosmos/queue
@@ -33,6 +33,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")
+artifact_sources = import_app_module("functions_generated_artifact_sources")
def load_functions(filename, names, namespace):
@@ -54,6 +55,7 @@ def normalizers():
"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,
+ "normalize_publication_source_kind": definitions.normalize_publication_source_kind,
})
@@ -550,6 +552,7 @@ def test_manual_route_uses_same_receipt_service_and_safe_errors(publication):
"get_current_user_info": lambda: {"displayName": "Actor"},
"_get_authorized_chat_artifact_message": publication.module._authorize_artifact,
"authorize_analysis_artifact": publication.module.authorize_analysis_artifact,
+ "authorize_generated_artifact_source": artifact_sources.authorize_generated_artifact_source,
"publish_generated_chat_artifact_for_user": publication.module.publish_generated_chat_artifact_for_user,
"log_event": lambda *args, **kwargs: None, "os": os,
}
@@ -602,6 +605,8 @@ def upload_blob(self, content, **kwargs):
"cosmos_conversations_container": types.SimpleNamespace(read_item=lambda **kwargs: {"user_id": "actor"}),
"build_conversation_participation_context": lambda *args: {"is_owner": True},
"analysis_artifact_metadata": saved_analysis.analysis_artifact_metadata,
+ "has_generated_artifact_source": artifact_sources.has_generated_artifact_source,
+ "generated_chat_artifact_address": artifact_sources.generated_chat_artifact_address,
"requires_generated_file_approval": lambda *args, **kwargs: False,
"CLIENTS": {"storage_account_office_docs_client": types.SimpleNamespace(get_blob_client=lambda **kwargs: blob)},
"storage_account_personal_chat_container_name": "chat",
diff --git a/functional_tests/test_chat_artifact_download_bytes.py b/functional_tests/test_chat_artifact_download_bytes.py
index 2656075fe..8235ada3e 100644
--- a/functional_tests/test_chat_artifact_download_bytes.py
+++ b/functional_tests/test_chat_artifact_download_bytes.py
@@ -1,7 +1,7 @@
# test_chat_artifact_download_bytes.py
"""
Functional regressions for authorized generated artifact download bytes.
-Version: 0.261.115
+Version: 0.261.119
Implemented in: 0.261.115
Production route, message/lifecycle authorization, internal blob reader, saved
@@ -11,6 +11,7 @@
"""
import ast
+from contextlib import ExitStack
import hashlib
import logging
import mimetypes
@@ -33,6 +34,7 @@
from test_saved_analysis_service import read_options, saved, saved_chat # noqa: F401
from test_content_screening_access import ScreeningAccessFixture, access as screening_access
from content_screening.contracts import DocumentHeldError, ScreeningError
+from functions_generated_artifact_sources import has_generated_artifact_source
APP = Path(__file__).resolve().parents[1] / "application" / "single_app"
@@ -116,6 +118,7 @@ def readall():
return SimpleNamespace(download_blob=lambda: SimpleNamespace(readall=readall))
namespace = {
+ "ExitStack": ExitStack, "has_generated_artifact_source": has_generated_artifact_source,
"hashlib": hashlib, "logging": logging, "mimetypes": mimetypes, "os": os,
"quote": quote, "secure_filename": secure_filename,
"Response": Response, "jsonify": jsonify, "request": request,
diff --git a/functional_tests/test_generated_artifact_lifecycle_authorization.py b/functional_tests/test_generated_artifact_lifecycle_authorization.py
index 5d1f9680b..d4f90fbd7 100644
--- a/functional_tests/test_generated_artifact_lifecycle_authorization.py
+++ b/functional_tests/test_generated_artifact_lifecycle_authorization.py
@@ -2,7 +2,7 @@
#!/usr/bin/env python3
"""
Functional test for generated artifact lifecycle authorization.
-Version: 0.250.180
+Version: 0.261.119
Implemented in: 0.250.180
This test ensures staged artifact-set members are not directly downloadable or
@@ -16,6 +16,9 @@
from datetime import datetime, timezone
from test_support.versioning import assert_app_version_at_least
+from test_support.app_stubs import import_app_module
+
+artifact_sources = import_app_module("functions_generated_artifact_sources")
ROOT = Path(__file__).resolve().parents[1]
@@ -81,6 +84,8 @@ def load_operation_helpers(conversation_item, message_item, run_item=None):
elif isinstance(node, ast.FunctionDef) and node.name in helper_names:
selected_nodes.append(node)
namespace = {
+ "has_generated_artifact_source": artifact_sources.has_generated_artifact_source,
+ "authorize_generated_artifact_source": artifact_sources.authorize_generated_artifact_source,
"Any": Any,
"Dict": Dict,
"Optional": Optional,
diff --git a/functional_tests/test_generated_file_saved_record_exports.py b/functional_tests/test_generated_file_saved_record_exports.py
new file mode 100644
index 000000000..72c16dace
--- /dev/null
+++ b/functional_tests/test_generated_file_saved_record_exports.py
@@ -0,0 +1,192 @@
+# test_generated_file_saved_record_exports.py
+"""
+Functional regression tests for shared exact saved-record exports.
+Version: 0.261.119
+Implemented in: 0.261.119
+
+Validate complete deterministic JSON, strict format/type/count boundaries,
+bounded streaming, cancellation, and cleanup without any external service.
+"""
+
+import hashlib
+import json
+from pathlib import Path
+import sys
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "application" / "single_app"))
+
+from functions_generated_file_exports import ( # noqa: E402
+ GeneratedFileExportRequest,
+ GeneratedFileExportStream,
+ build_generated_file_export,
+)
+
+
+class RecordSource:
+ kind = "records"
+
+ def __init__(self, records, count):
+ self.records = records
+ self.record_count = count
+ self.reads = 0
+ self.checks = 0
+
+ def iter_records(self):
+ for record in self.records:
+ self.reads += 1
+ yield record
+
+ def recheck(self):
+ self.checks += 1
+
+
+def render(source, *, limit=1024 * 1024, output_format="json", profile="exact_records_v1", check=None):
+ return build_generated_file_export(
+ source=source, export_request=GeneratedFileExportRequest(output_format, profile),
+ max_output_bytes=limit, check=check,
+ )
+
+
+def test_exact_saved_values_order_multiplicity_and_digest():
+ repeated = {"values": {"b": None, "a": [False, 0, "\u03bb", {"nested": "a\nb"}]}, "evidence": ["original"]}
+ records = [repeated, {"middle-only": "\U0001f600", "integer": 9007199254740993}, repeated]
+ source = RecordSource(iter(records), len(records))
+ expected = json.dumps(records, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False).encode("ascii")
+ with render(source, limit=len(expected)) as exported:
+ assert isinstance(exported, GeneratedFileExportStream)
+ assert exported.file_content.read() == expected
+ assert exported.record_count == len(records)
+ assert exported.content_sha256 == hashlib.sha256(expected).hexdigest()
+ assert exported.size_bytes == len(expected)
+ assert exported.media_type == "application/json"
+ assert exported.file_content.closed
+ assert source.reads == 3 and source.checks == 2
+
+
+@pytest.mark.parametrize("count", [0, 1, 100, 101, 10001])
+def test_every_record_is_streamed_without_a_preview_limit(count):
+ source = RecordSource(({"ordinal": index} for index in range(count)), count)
+ with render(source) as exported:
+ values = json.load(exported.file_content)
+ assert values == [{"ordinal": index} for index in range(count)]
+ assert source.reads == count and exported.record_count == count
+
+
+def test_collection_over_materialization_bound_uses_incremental_reads():
+ count = 1100
+ source = RecordSource(({"index": index, "value": "x" * 8192} for index in range(count)), count)
+ checks = []
+ with render(source, limit=10 * 1024 * 1024, check=lambda: checks.append(source.reads)) as exported:
+ assert exported.size_bytes > 8 * 1024 * 1024
+ digest = hashlib.sha256()
+ for chunk in iter(lambda: exported.file_content.read(65536), b""):
+ digest.update(chunk)
+ assert digest.hexdigest() == exported.content_sha256
+ assert 100 < len(checks) < 200
+
+
+@pytest.mark.parametrize("output_format", ["csv", "md", "docx", "pdf", "pptx", "xml", "JSON", ""])
+def test_no_unsupported_format_falls_back_to_json(output_format):
+ source = RecordSource([{"a": 1}], 1)
+ with pytest.raises(ValueError, match="not supported"):
+ render(source, output_format=output_format)
+ assert source.reads == source.checks == 0
+
+
+@pytest.mark.parametrize("limit", [None, True, 0, -1, 1.5])
+def test_explicit_sources_require_a_positive_integer_byte_limit(limit):
+ source = RecordSource([{"value": 1}], 1)
+ with pytest.raises(ValueError, match="byte limit"):
+ render(source, limit=limit)
+ assert source.reads == source.checks == 0
+
+
+@pytest.mark.parametrize("invalid", ["missing_source", "missing_request", "competing_analysis"])
+def test_explicit_source_selection_never_falls_back_to_response_rendering(invalid):
+ source = RecordSource([{"value": 1}], 1)
+ options = {
+ "source": source, "export_request": GeneratedFileExportRequest("json"),
+ "max_output_bytes": 1024,
+ }
+ if invalid == "missing_source":
+ options.pop("source")
+ elif invalid == "missing_request":
+ options.pop("export_request")
+ else:
+ options["analysis_result"] = {}
+ with pytest.raises(ValueError, match="exactly one saved source"):
+ build_generated_file_export("create a CSV", "Do not export this summary.", **options)
+ assert source.reads == source.checks == 0
+
+
+@pytest.mark.parametrize("value", [
+ {1: "not-a-string-key"}, {"value": object()}, {"value": float("nan")},
+ {"value": float("inf")}, {"value": (1, 2)}, "not-an-object", None,
+])
+def test_non_json_or_non_record_values_are_rejected(value):
+ with pytest.raises(ValueError):
+ render(RecordSource([value], 1))
+
+
+@pytest.mark.parametrize("records,count", [([{"a": 1}], 0), ([], 1), ([{"a": 1}], 2)])
+def test_count_mismatch_never_returns_a_prefix(records, count):
+ with pytest.raises(ValueError, match="count"):
+ render(RecordSource(records, count))
+
+
+def test_quota_counts_delimiters_and_escaped_bytes():
+ records = [{"text": "\u03bb" * 20}, {"text": ""}]
+ expected = json.dumps(records, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii")
+ with render(RecordSource(records, 2), limit=len(expected)) as exported:
+ assert exported.size_bytes == len(expected)
+ with pytest.raises(ValueError, match="size limit"):
+ render(RecordSource(records, 2), limit=len(expected) - 1)
+ with render(RecordSource([], 0), limit=2) as exported:
+ assert exported.file_content.read() == b"[]"
+ with pytest.raises(ValueError, match="size limit"):
+ render(RecordSource([], 0), limit=1)
+
+
+def test_failure_and_cancellation_close_private_temporary_output(monkeypatch):
+ import functions_generated_file_exports as exports
+
+ original = exports.tempfile.TemporaryFile
+ opened = []
+
+ def tracked(*args, **kwargs):
+ stream = original(*args, **kwargs)
+ opened.append(stream)
+ return stream
+
+ monkeypatch.setattr(exports.tempfile, "TemporaryFile", tracked)
+ source = RecordSource(({"a": index} for index in range(500)), 500)
+
+ def cancel():
+ if source.reads >= 100:
+ raise PermissionError("run no longer owned")
+
+ with pytest.raises(PermissionError):
+ render(source, check=cancel)
+ assert source.reads == 100 and all(stream.closed for stream in opened)
+ with pytest.raises(ValueError):
+ render(RecordSource([{"a": "large"}], 1), limit=2)
+ assert len(opened) == 2 and all(stream.closed for stream in opened)
+
+
+def test_final_authorization_failure_does_not_return_artifact():
+ source = RecordSource([{"a": 1}], 1)
+
+ def revoke():
+ source.checks += 1
+ if source.checks > 1:
+ raise PermissionError("source revoked")
+
+ source.recheck = revoke
+ with pytest.raises(PermissionError):
+ render(source)
+
+
+if __name__ == "__main__":
+ raise SystemExit(pytest.main([__file__, "-q"]))
diff --git a/functional_tests/test_workflow_collect_publication.py b/functional_tests/test_workflow_collect_publication.py
new file mode 100644
index 000000000..919c76326
--- /dev/null
+++ b/functional_tests/test_workflow_collect_publication.py
@@ -0,0 +1,366 @@
+# test_workflow_collect_publication.py
+"""
+Functional tests for real Collect-to-shared-export-to-publication execution.
+Version: 0.261.119
+Implemented in: 0.261.119
+
+Native per-document results, frozen iteration, schema-2 replay, both result
+backends, shared JSON rendering and the existing destination ledger execute
+against closed stores. No live documents, permissions, workflows or models.
+"""
+
+from copy import deepcopy
+import hashlib
+import json
+
+import pytest
+
+from test_analyze_native_saved_integration import native_run
+from test_analysis_artifact_publication import publication, normalizers
+from test_workflow_loop_native_analysis import native_loop_flow
+from test_workflow_saved_output_artifacts import artifact_services, saved_output_artifact
+from test_workflow_for_each_execution import loop_definition
+from functions_analysis_access import AnalysisResultUnavailable
+from functions_artifact_publication_readiness import begin_publication_processing, finish_publication_processing
+from functions_workflow_definitions import WorkflowDefinitionError
+from functions_workflow_execution import WorkflowSuspended
+from functions_workflow_flow import compile_workflow_flow
+from functions_workflow_identity import workflow_execution_id
+from functions_workflow_node_results import open_workflow_record_input
+from functions_workflow_readiness import workflow_outputs_ready
+
+
+def publication_config(policy="submitted", scope="personal"):
+ return {
+ "source_kind": "saved_output", "artifact_format": "json", "workspace_scope": scope,
+ **({"completion_policy": policy} if policy else {}),
+ **({"group_id": "fixed-group"} if scope == "group" else {}),
+ **({"public_workspace_id": "fixed-public"} if scope == "public" else {}),
+ }
+
+
+@pytest.mark.parametrize("native_loop_flow", [
+ {"storage": storage, "join": join, "publication": publication_config()}
+ for storage in ("cosmos", "blob") for join in (False, True)
+], indirect=True)
+def test_reloaded_native_collect_publishes_without_reanalysis(artifact_services, native_loop_flow):
+ fixture, services = native_loop_flow, artifact_services
+ services.bind(fixture["workflow"], fixture["store"])
+ execute = fixture["execute"]
+
+ def interrupt():
+ raise SystemExit("closed-fixture restart after exact Collect, before file output")
+
+ with pytest.raises(SystemExit):
+ execute(before_publication=interrupt)
+ assert len(fixture["calls"]) == 2 and len(fixture["native_run"].reads) == 6
+ assert services.blobs.writes == 0
+ completed = execute()
+ assert completed["workflow_outcome"] == {"status": "completed", "success": True}
+ assert completed["publication"]["policy_satisfied"] is True
+ assert completed["publication"]["state"] == "submitted"
+ file_card = completed["generated_tabular_outputs"][-1]
+ assert file_card["capability"] == "file_export" and file_card["row_count"] == 300
+ assert file_card["source_kind"] == "workflow_saved_output"
+ assert not ({"blob_path", "blob_container", "source_binding", "analysis_producer"} & file_card.keys())
+ artifact = services.publication.messages.records[file_card["artifact_message_id"]]
+ binding = artifact["metadata"]["generated_artifact_source"]
+ assert binding["producer"]["node_id"] == ("selected" if fixture["options"]["join"] else "collect")
+ assert "task_id" not in binding["producer"]
+ assert not artifact["metadata"].get("analysis_result_required")
+ content = services.blobs.data[(artifact["blob_container"], artifact["blob_path"])]
+ rows = json.loads(content)
+ assert len(rows) == 300
+ assert [record["values"] for record in rows[:150]] == fixture["native_run"].rows
+ assert {record["document_id"] for record in rows[:150]} == {"native-source-a"}
+ assert {record["document_id"] for record in rows[150:]} == {"native-source-b"}
+ assert artifact["metadata"]["generated_artifact_content_sha256"] == hashlib.sha256(content).hexdigest()
+ assert services.publication.calls["queue"][0]["file_content_bytes"] == content
+ receipt = completed["_publication_request"]["source_receipt"]
+ assert receipt["result_ref"]["storage"] == fixture["options"]["storage"]
+ reader = open_workflow_record_input(
+ fixture["workflow"], "run", receipt["producer"], receipt["result_ref"],
+ output_name=receipt["output_name"],
+ )
+ assert not reader.manifest.get("analysis_origin")
+ before = deepcopy(services.publication.calls)
+ assert execute()["publication"] == completed["publication"]
+ assert services.publication.calls["create"] == before["create"]
+ assert services.publication.calls["queue"] == before["queue"]
+ assert services.blobs.writes == 1
+ assert len(fixture["calls"]) == 2 and len(fixture["native_run"].reads) == 6
+
+ fixture["allowed"]["native-source-b"] = False
+ with pytest.raises((PermissionError, AnalysisResultUnavailable, ValueError)):
+ services.download("owner", "conversation-1", file_card["artifact_message_id"])
+ assert len(services.publication.calls["create"]) == 1
+
+
+@pytest.mark.parametrize("native_loop_flow", [
+ {"storage": storage, "publication": publication_config("indexed_ready", scope)}
+ for storage in ("cosmos", "blob") for scope in ("personal", "group", "public")
+], indirect=True)
+def test_generic_publication_reuses_native_readiness_and_completion(artifact_services, native_loop_flow, monkeypatch, tmp_path):
+ fixture, services = native_loop_flow, artifact_services
+ publication = services.publication
+ services.bind(fixture["workflow"], fixture["store"])
+ with pytest.raises(WorkflowSuspended):
+ fixture["execute"]()
+ initial = fixture["store"].read()
+ assert initial["state"] == "waiting_output"
+ scope = fixture["options"]["publication"]["workspace_scope"]
+ document = deepcopy(next(iter(publication.destinations[scope].records.values())))
+ if scope != "personal":
+ assert initial["gate"]["publication"]["state"] == "waiting_approval"
+ publication.state["group_role"] = "DocumentManager"
+ publication.module.decide_artifact_publication("reviewer", document, "approved")
+ document = deepcopy(publication.destinations[scope].records[document["id"]])
+ artifact = next(item for item in publication.messages.records.values()
+ if item.get("metadata", {}).get("generated_artifact_source_required"))
+ content = services.blobs.data[(artifact["blob_container"], artifact["blob_path"])]
+ path = tmp_path / "accepted-records.json"
+ path.write_bytes(content)
+ begin_publication_processing(document, path)
+ finish_publication_processing(document, indexed_chunks=2)
+ indexed = {"count": 1}
+ monkeypatch.setattr("functions_artifact_publication_readiness._index_count", lambda *args: indexed["count"])
+
+ def resume():
+ 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):
+ resume()
+ gate = fixture["store"].read()
+ assert gate["gate"]["publication"]["state"] == "waiting_index"
+ assert gate["admitted_count"] == initial["admitted_count"]
+ assert gate["deadline_at"] == initial["deadline_at"]
+ assert gate["gate"]["attempt"] == 1
+ indexed["count"] = 2
+ result = resume()
+ assert result["publication"]["state"] == "indexed_ready" and result["publication"]["policy_satisfied"]
+ assert result["workflow_outcome"] == {"status": "completed", "success": True}
+ assert publication.calls["queue"][0]["file_content_bytes"] == content
+ assert len(publication.calls["queue"]) == len(publication.calls["create"]) == 1
+ assert services.blobs.writes == 1
+ assert len(fixture["calls"]) == 2
+ assert len(publication.calls["notify"]) == (0 if scope == "personal" else 3)
+ snapshot = deepcopy(result["publication"])
+ indexed["count"] = 0
+ assert fixture["execute"]()["publication"] == snapshot
+
+
+@pytest.mark.parametrize("policy", [None, "submitted", "approved"])
+@pytest.mark.parametrize("scope", ["personal", "group", "public"])
+def test_non_analyze_records_use_the_same_receipts_and_explicit_policy(saved_output_artifact, policy, scope):
+ fixture = saved_output_artifact
+ artifact = fixture.materialize()
+ publication = fixture.services.publication
+ request = {
+ "publication": publication_config(policy, scope),
+ "artifact_reference": {
+ "conversation_id": "conversation-1", "artifact_message_id": artifact["artifact_message_id"],
+ "producer": {"kind": "workflow_saved_output", **fixture.receipt["producer"]},
+ },
+ "request_id": "workflow-publication:v3:publisher:collect:1",
+ "source_receipt": fixture.receipt,
+ }
+ result = publication.module.publish_workflow_artifact("owner", **request)
+ if scope == "personal":
+ assert result["publication"]["state"] == (policy or "queued")
+ elif policy == "approved":
+ assert result["publication"]["state"] == "waiting_approval"
+ else:
+ assert result["publication"]["state"] == (policy or "pending_approval")
+ assert len(publication.calls["create"]) == 1
+ retry = publication.module.publish_workflow_artifact("owner", **request)
+ assert retry["publication"] == result["publication"]
+ assert len(publication.calls["create"]) == 1
+ if scope != "personal":
+ publication.state["group_role"] = "DocumentManager"
+ document = deepcopy(next(iter(publication.destinations[scope].records.values())))
+ assert document["generated_artifact_publication_binding"]["receipt_id"] == result["publication"]["id"]
+ publication.module.decide_artifact_publication("reviewer", document, "approved")
+ publication.module.decide_artifact_publication("reviewer", deepcopy(publication.destinations[scope].records[document["id"]]), "approved")
+ assert len(publication.calls["queue"]) == 1
+
+
+@pytest.mark.parametrize("native_loop_flow", [{"publication": publication_config("indexed_ready")}], indirect=True)
+def test_lost_queue_ack_preserves_artifact_and_destination_without_unbounded_retry(artifact_services, native_loop_flow):
+ fixture, services = native_loop_flow, artifact_services
+ services.bind(fixture["workflow"], fixture["store"])
+ services.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"
+ 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-{index}",
+ )
+ with pytest.raises(WorkflowSuspended):
+ fixture["execute"]()
+ control = fixture["store"].read()
+ assert len(services.publication.calls["create"]) == len(services.publication.calls["queue"]) == 1
+ assert services.blobs.writes == 1 and len(fixture["calls"]) == 2
+ assert control["gate"]["attempt"] == 1
+
+
+@pytest.mark.parametrize("native_loop_flow", [
+ {"publication": publication_config(), "partial": True, "accept_partial": accept}
+ for accept in (False, True)
+], indirect=True)
+def test_runner_uses_the_explicit_partial_input_policy(artifact_services, native_loop_flow):
+ fixture, services = native_loop_flow, artifact_services
+ services.bind(fixture["workflow"], fixture["store"])
+ if fixture["options"]["accept_partial"]:
+ result = fixture["execute"]()
+ assert result["workflow_outcome"]["status"] == "completed_partial"
+ assert result["publication"]["policy_satisfied"]
+ card = result["generated_tabular_outputs"][-1]
+ assert card["row_count"] == 150 and "accepted_partial" in card["summary"]
+ assert services.blobs.writes == 1
+ else:
+ with pytest.raises(WorkflowSuspended):
+ fixture["execute"]()
+ assert fixture["store"].read()["state"] == "paused"
+ assert services.blobs.writes == 0 and not services.publication.calls["create"]
+ assert len(fixture["calls"]) == 1
+
+
+@pytest.mark.parametrize("policy", [None, "submitted"])
+@pytest.mark.parametrize("stage,scope,effect,count", [
+ ("create", "group", "create", 0),
+ ("prepare", "group", "update", 0),
+ ("queue", "personal", "queue", 0),
+ ("workspace_notification", "group", "notify", 0),
+ ("submitter_notification", "group", "notify", 1),
+])
+@pytest.mark.parametrize("revocation", ["source", "destination"])
+def test_generic_publication_reauthorizes_after_stage_claims(
+ saved_output_artifact, monkeypatch, policy, stage, scope, effect, count, revocation,
+):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ artifact = fixture.materialize()
+ publication = services.publication
+ original = publication.module._stage
+
+ def claimed(*args, **kwargs):
+ acquired = original(*args, **kwargs)
+ if args[2] == stage and not kwargs.get("complete"):
+ if revocation == "source" or scope == "personal":
+ services.state["workflow_allowed"] = False
+ else:
+ publication.state["workspace_status"] = "locked"
+ return acquired
+
+ monkeypatch.setattr(publication.module, "_stage", claimed)
+ with pytest.raises((PermissionError, AnalysisResultUnavailable)):
+ publication.module.publish_workflow_artifact(
+ "owner", publication=publication_config(policy, scope),
+ artifact_reference={
+ "conversation_id": "conversation-1", "artifact_message_id": artifact["artifact_message_id"],
+ "producer": {"kind": "workflow_saved_output", **fixture.receipt["producer"]},
+ },
+ request_id="closed-stage-race", source_receipt=fixture.receipt,
+ )
+ assert len(publication.calls[effect]) == count
+ assert services.blobs.writes == 1
+
+
+@pytest.mark.parametrize("boundary", ["byte_read", "decision", "approval_queue"])
+def test_generic_approval_rechecks_source_before_each_handoff(saved_output_artifact, monkeypatch, boundary):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ artifact = fixture.materialize()
+ publication = services.publication
+ result = publication.module.publish_workflow_artifact(
+ "owner", publication=publication_config("indexed_ready", "group"),
+ artifact_reference={
+ "conversation_id": "conversation-1", "artifact_message_id": artifact["artifact_message_id"],
+ "producer": {"kind": "workflow_saved_output", **fixture.receipt["producer"]},
+ },
+ request_id="closed-approval-race", source_receipt=fixture.receipt,
+ )
+ publication.state["group_role"] = "DocumentManager"
+ document = deepcopy(next(iter(publication.destinations["group"].records.values())))
+ assert document["generated_artifact_publication_receipt_id"] == result["publication"]["id"]
+ if boundary == "byte_read":
+ services.blobs.read_hook = lambda: services.state.update(workflow_allowed=False)
+ elif boundary == "decision":
+ original = publication.module._receipt_change
+
+ def decision(*args, **kwargs):
+ receipt, changed = original(*args, **kwargs)
+ if changed and (receipt.get("decision") or {}).get("choice") == "approved":
+ services.state["workflow_allowed"] = False
+ return receipt, changed
+
+ monkeypatch.setattr(publication.module, "_receipt_change", decision)
+ else:
+ original = publication.module._stage
+
+ def queue_claim(*args, **kwargs):
+ changed = original(*args, **kwargs)
+ if args[2] == "approval_queue" and not kwargs.get("complete"):
+ services.state["workflow_allowed"] = False
+ return changed
+
+ monkeypatch.setattr(publication.module, "_stage", queue_claim)
+ with pytest.raises((PermissionError, RuntimeError)):
+ publication.module.decide_artifact_publication("reviewer", document, "approved")
+ assert publication.calls["queue"] == []
+ current = publication.destinations["group"].records[document["id"]]
+ assert current["generated_artifact_promotion_status"] == (
+ "approval_failed" if boundary == "approval_queue" else "pending_approval"
+ )
+
+
+def test_publication_source_normalization_preserves_omission_and_rejects_unknown():
+ normalize = normalizers()["normalize_workflow_publication"]
+ native = {"artifact_format": "md", "workspace_scope": "personal"}
+ assert normalize(native) == native
+ assert normalize({**native, "source_kind": "native_analysis"})["source_kind"] == "native_analysis"
+ assert normalize(publication_config()) == publication_config()
+ for value in (None, "", "inferred", True):
+ with pytest.raises(ValueError):
+ normalize({**native, "source_kind": value})
+ for value in ("md", "csv", "xml", "docx", "pdf", "pptx"):
+ with pytest.raises(ValueError):
+ normalize({**publication_config(), "artifact_format": value})
+
+
+@pytest.mark.parametrize("mutation", [
+ lambda wf: wf["tasks"][-1]["publication"].update(artifact_format="csv"),
+ lambda wf: wf["tasks"][-1]["inputs"][0].update(required=False),
+ lambda wf: wf["tasks"][-1]["inputs"][0].update(expected_kind="json"),
+ lambda wf: wf["tasks"][-1]["inputs"].clear(),
+ lambda wf: wf["tasks"][-1]["inputs"][0]["source"].update(output="text"),
+ lambda wf: wf["tasks"][-1]["publication"].update(source_kind=None),
+ lambda wf: wf.update(durable_execution=False),
+])
+def test_invalid_generic_source_contract_fails_in_the_compiler(mutation):
+ workflow = loop_definition()
+ workflow["tasks"].append({
+ "id": "publish", "type": "instructions", "instructions": "Publish exact records.", "name": "Publish",
+ "runner": {"type": "inherit"}, "document_action": {"type": "none"},
+ "publication": publication_config(), "output_contract": {"kind": "json"},
+ "inputs": [{
+ "name": "deliverable",
+ "source": {"kind": "node_output", "node_id": "collect", "output": "records"},
+ "required": True, "expected_kind": "records",
+ }],
+ })
+ workflow["flow"]["nodes"].append({"id": "publish-node", "kind": "task", "task_id": "publish"})
+ compile_workflow_flow(workflow)
+ mutation(workflow)
+ with pytest.raises(WorkflowDefinitionError):
+ compile_workflow_flow(workflow)
+
+
+if __name__ == "__main__":
+ raise SystemExit(pytest.main([__file__, "-q"]))
diff --git a/functional_tests/test_workflow_loop_native_analysis.py b/functional_tests/test_workflow_loop_native_analysis.py
index e55df3667..73ff63487 100644
--- a/functional_tests/test_workflow_loop_native_analysis.py
+++ b/functional_tests/test_workflow_loop_native_analysis.py
@@ -1,7 +1,7 @@
# test_workflow_loop_native_analysis.py
"""
Functional regression for current-document native Analyze in serial loops.
-Version: 0.261.117
+Version: 0.261.119
Implemented in: 0.261.117
Production task dispatch, native checkpoint adaptation, result transport and
@@ -17,6 +17,7 @@
from test_analyze_native_saved_integration import native, native_run # noqa: F401
from test_analyze_backend_saved_integration import saved
from test_workflow_for_each_execution import loop_definition, loop_runtime
+from test_workflow_result_store import FakeBlobService
from test_workflow_task_result_handoff import build_inventory_run
from functions_analysis_access import AnalysisResultUnavailable, authorize_analysis_sources
from functions_document_analysis_checkpoints import analysis_checkpoints_for_workflow
@@ -29,7 +30,9 @@
from functions_workflow_structured_execution import StructuredWorkflowExecution
-def test_each_document_uses_its_exact_native_producer_and_collects_complete_results(native_run, monkeypatch):
+@pytest.fixture
+def native_loop_flow(native_run, monkeypatch, request):
+ options = getattr(request, "param", None) or {}
definition = loop_definition()
definition["tasks"] = definition["tasks"][1:]
definition["tasks"][0]["document_action"] = {
@@ -42,7 +45,47 @@ def test_each_document_uses_its_exact_native_producer_and_collects_complete_resu
loop["iterable"] = {
"kind": "documents", "documents": [{"document_id": key, "scope_type": "personal"} for key in identifiers],
}
+ if options.get("partial"):
+ loop["body"]["nodes"][0]["run_when"] = {
+ "op": "lt", "left": {"input": "item", "path": "/index"}, "right": {"literal": 1},
+ }
+ loop["body"]["outputs"][0]["required"] = False
+ definition["flow"]["nodes"][1]["output_contract"].update(allow_partial=True, require_complete_coverage=False)
+ definition["flow"]["outputs"][0]["allow_partial"] = True
+ if options.get("publication") is not None:
+ publish_binding = {
+ "name": "deliverable",
+ "source": {"kind": "node_output", "node_id": "collect", "output": "records", "scope": "current"},
+ "required": True, "expected_kind": "records",
+ **({"allow_partial": options["accept_partial"]} if "accept_partial" in options else {}),
+ }
+ if options.get("join"):
+ definition["flow"]["nodes"].append({
+ "id": "choose", "kind": "if", "inputs": [],
+ "condition": {"op": "eq", "left": {"literal": True}, "right": {"literal": True}},
+ "then": {"id": "then-region", "nodes": []},
+ "else": {"id": "else-region", "nodes": []},
+ "join": {"id": "selected", "exports": [{
+ "name": "deliverable",
+ "then": {"node_id": "collect", "output": "records"},
+ "else": {"node_id": "collect", "output": "records"},
+ "required": True, "expected_kind": "records",
+ }]},
+ })
+ publish_binding["source"].update(node_id="selected", output="deliverable")
+ definition["tasks"].append({
+ "id": "publish", "type": "instructions", "name": "Publish records", "instructions": "Publish exact records.",
+ "runner": {"type": "inherit"}, "document_action": {"type": "none"},
+ "inputs": [publish_binding], "output_contract": {"kind": "json"},
+ "publication": copy.deepcopy(options["publication"]),
+ })
+ definition["flow"]["nodes"].append({"id": "publish-node", "kind": "task", "task_id": "publish"})
workflow, store, container, _ = loop_runtime(monkeypatch, definition=definition)
+ if options.get("storage") == "blob":
+ blobs = FakeBlobService()
+ configured = lambda *args, **kwargs: WorkflowResultStore(container, blobs, "private-workflow-results")
+ monkeypatch.setattr("functions_workflow_result_store._configured_store", configured)
+ monkeypatch.setattr("functions_workflow_result_store._configured_result_store", configured)
sources = {
key: {
**copy.deepcopy(native_run.source), "document_id": key, "scope_id": "owner",
@@ -92,6 +135,7 @@ def read_native(user_id, run_id):
}
monkeypatch.setattr(native_module, "_read_run", read_native)
+ monkeypatch.setitem(sys.modules, "functions_saved_analysis", saved)
runner, _, _, _, _, _ = build_inventory_run()
calls, bindings = [], []
@@ -132,13 +176,39 @@ def bind(user, conversation, native_id, artifacts, bound_producer):
),
)
- def execute():
+ def execute(*, before_publication=None):
+ original_publication = runner["_execute_workflow_analysis_publication"]
+
+ def publication_dispatch(*args, **kwargs):
+ if before_publication:
+ before_publication()
+ return original_publication(*args, **kwargs)
+
+ runner["_execute_workflow_analysis_publication"] = publication_dispatch
with WorkflowRuntimeLease(store, owner_id="native-loop-worker") as lease:
execution = StructuredWorkflowExecution(store, lease, workflow, "run")
- with workflow_execution_scope(execution):
- return runner["_execute_workflow_task_sequence"](
- workflow, {}, "conversation-1", "run", None, {}, actor_user_id="owner",
- )
+ try:
+ with workflow_execution_scope(execution):
+ return runner["_execute_workflow_task_sequence"](
+ workflow, {}, "conversation-1", "run", None, {}, actor_user_id="owner",
+ )
+ finally:
+ runner["_execute_workflow_analysis_publication"] = original_publication
+
+ return {
+ "execute": execute, "workflow": workflow, "store": store, "container": container,
+ "calls": calls, "bindings": bindings, "identifiers": identifiers, "allowed": allowed,
+ "source_resolver": source_resolver, "native_run": native_run, "options": options,
+ }
+
+
+def test_each_document_uses_its_exact_native_producer_and_collects_complete_results(native_loop_flow):
+ fixture = native_loop_flow
+ execute, workflow, calls, bindings, identifiers, allowed, source_resolver, native_run = (
+ fixture[key] for key in (
+ "execute", "workflow", "calls", "bindings", "identifiers", "allowed", "source_resolver", "native_run",
+ )
+ )
result = execute()
assert result["workflow_outcome"] == {"status": "completed", "success": True}
diff --git a/functional_tests/test_workflow_saved_output_artifacts.py b/functional_tests/test_workflow_saved_output_artifacts.py
new file mode 100644
index 000000000..f433f86e7
--- /dev/null
+++ b/functional_tests/test_workflow_saved_output_artifacts.py
@@ -0,0 +1,713 @@
+# test_workflow_saved_output_artifacts.py
+"""
+Functional tests for immutable, source-authorized workflow saved-output files.
+Version: 0.261.119
+Implemented in: 0.261.119
+
+Use real Collect, journal, result stores, shared renderer/uploader, and authorized
+download functions with private in-memory service doubles. No live Azure access.
+"""
+
+from contextlib import contextmanager, ExitStack
+from collections.abc import Mapping
+from copy import deepcopy
+from datetime import datetime, timezone
+import hashlib
+import io
+from importlib import import_module
+import json
+import mimetypes
+import os
+import sys
+import tempfile
+from types import SimpleNamespace
+from typing import Any, Dict, Optional
+from urllib.parse import quote
+import uuid
+
+from azure.core.exceptions import ResourceExistsError
+from azure.cosmos.exceptions import CosmosResourceExistsError, CosmosResourceNotFoundError
+from flask import Flask, Response, g
+import pytest
+from werkzeug.utils import secure_filename
+
+from test_analysis_artifact_publication import (
+ artifact_sources, load_functions, publication, saved_analysis,
+)
+from test_workflow_for_each_execution import execute_loop, loop_definition, loop_runtime
+from test_workflow_result_store import FakeBlobService
+from functions_analysis_access import AnalysisResultUnavailable
+from content_screening.contracts import DocumentHeldError
+from functions_workflow_artifacts import (
+ WorkflowRecordExportSource,
+ authorize_workflow_saved_output_artifact,
+ load_workflow_artifact_binding,
+ materialize_workflow_saved_output,
+)
+from functions_workflow_bindings import WorkflowInputError
+from functions_workflow_execution import workflow_execution_scope
+from functions_workflow_identity import workflow_execution_id
+from functions_workflow_node_results import open_workflow_record_input
+from functions_workflow_result_store import WorkflowResultStore
+from functions_workflow_runtime_store import WorkflowRuntimeLease
+from functions_workflow_structured_execution import StructuredWorkflowExecution
+
+
+class ArtifactBlobs:
+ def __init__(self):
+ self.data = {}
+ self.writes = 0
+ self.reads = []
+ self.fail_after_upload = False
+ self.read_hook = None
+
+ def get_blob_client(self, *, container, blob):
+ service, key = self, (container, blob)
+
+ class Blob:
+ def exists(self):
+ return key in service.data
+
+ def upload_blob(self, data, *, overwrite, **kwargs):
+ if key in service.data and not overwrite:
+ raise ResourceExistsError("existing")
+ assert hasattr(data, "read"), "The production upload must receive a bounded stream."
+ service.data[key] = b"".join(iter(lambda: data.read(65536), b""))
+ service.writes += 1
+ if service.fail_after_upload:
+ service.fail_after_upload = False
+ raise TimeoutError("closed-fixture lost upload acknowledgement")
+
+ def download_blob(self):
+ assert key in service.data, "No missing blob may be silently recreated."
+
+ def chunks():
+ content = service.data[key]
+ for offset in range(0, len(content), 4096):
+ service.reads.append(4096)
+ if service.read_hook:
+ service.read_hook()
+ yield content[offset:offset + 4096]
+
+ return SimpleNamespace(chunks=chunks)
+
+ return Blob()
+
+
+@pytest.fixture
+def artifact_services(publication, monkeypatch):
+ services = publication
+ blobs = ArtifactBlobs()
+ state = {"workflow_allowed": True, "conversation_allowed": True, "workflow": None, "store": None}
+ settings = {}
+ services.conversations.put({"id": "conversation-1", "user_id": "owner"})
+ operations_module = sys.modules["functions_simplechat_operations"]
+ original_queue = services.module.queue_generated_document_processing
+
+ def queue_content(**values):
+ content = values["file_content_bytes"]
+ if hasattr(content, "read"):
+ content.seek(0)
+ values = {**values, "file_content_bytes": content.read()}
+ return original_queue(**values)
+
+ monkeypatch.setattr(services.module, "queue_generated_document_processing", queue_content)
+
+ def conversation_access(user_id, conversation):
+ if user_id != "owner" or not state["conversation_allowed"]:
+ raise PermissionError("closed-fixture conversation revoked")
+ return {"is_owner": True}
+
+ monkeypatch.setattr(services.module, "build_conversation_participation_context", conversation_access)
+
+ def create_message(body):
+ if body["id"] in services.messages.records:
+ raise CosmosResourceExistsError(status_code=409, message="existing")
+ return services.messages.put(body)
+
+ monkeypatch.setattr(services.messages, "create_item", create_message, raising=False)
+ namespace = {
+ "Any": Any, "Dict": Dict, "Optional": Optional, "hashlib": hashlib,
+ "datetime": datetime, "timezone": timezone, "os": os, "uuid": uuid, "tempfile": tempfile,
+ "ResourceExistsError": ResourceExistsError, "CosmosResourceExistsError": CosmosResourceExistsError,
+ "CosmosResourceNotFoundError": CosmosResourceNotFoundError,
+ "cosmos_conversations_container": services.conversations, "cosmos_messages_container": services.messages,
+ "build_conversation_participation_context": conversation_access,
+ "analysis_artifact_metadata": saved_analysis.analysis_artifact_metadata,
+ "authorize_analysis_artifact": saved_analysis.authorize_analysis_artifact,
+ "requires_generated_file_approval": lambda *args, **kwargs: False,
+ "CLIENTS": {"storage_account_office_docs_client": blobs},
+ "storage_account_personal_chat_container_name": "chat",
+ "_get_latest_personal_thread_id": lambda *args: None,
+ "_build_generated_chat_artifact_lifecycle_metadata": lambda *args, **kwargs: {},
+ "_build_generated_chat_artifact_lifecycle_response": lambda *args: {},
+ "_generated_artifact_has_lifecycle_contract": lambda metadata: False,
+ "_normalize_generated_document_file_name": lambda value: value,
+ "allowed_file": lambda name: name.endswith(".json"),
+ "get_settings": lambda: settings,
+ "TABULAR_EXTENSIONS": {"csv", "json"}, "log_event": lambda *args, **kwargs: None,
+ **{name: getattr(artifact_sources, name) for name in (
+ "generated_chat_artifact_address", "generated_artifact_source_metadata",
+ "authorize_generated_artifact_preparation", "authorize_generated_artifact_source",
+ "has_generated_artifact_source",
+ )},
+ }
+ operations = load_functions("functions_simplechat_operations.py", {
+ "upload_generated_file_artifact_stream_for_user", "_upload_generated_chat_artifact_for_current_user",
+ "_verify_generated_artifact_blob", "open_generated_chat_artifact_stream",
+ "assert_generated_chat_artifact_is_published_for_user",
+ "_write_temp_generated_file", "queue_generated_document_processing",
+ }, namespace)
+ operations["open_generated_chat_artifact_stream"] = contextmanager(operations["open_generated_chat_artifact_stream"])
+ for name in (
+ "upload_generated_file_artifact_stream_for_user", "open_generated_chat_artifact_stream",
+ "assert_generated_chat_artifact_is_published_for_user",
+ ):
+ monkeypatch.setattr(operations_module, name, operations[name], raising=False)
+ monkeypatch.setattr(services.module, "assert_generated_chat_artifact_is_published_for_user",
+ operations["assert_generated_chat_artifact_is_published_for_user"])
+ monkeypatch.setattr(sys.modules["config"], "storage_account_personal_chat_container_name", "chat", raising=False)
+
+ def lookup(scope_id, workflow_id):
+ workflow = state["workflow"]
+ if not state["workflow_allowed"] or not workflow or workflow_id != workflow["id"]:
+ return None
+ if scope_id != (workflow.get("group_id") or workflow["user_id"]):
+ return None
+ return deepcopy(workflow)
+
+ def run_lookup(scope_id, run_id):
+ workflow = state["workflow"]
+ if not workflow or run_id != "run" or not lookup(scope_id, workflow["id"]):
+ return None
+ return {"id": "run", "workflow_id": workflow["id"], "conversation_id": "conversation-1"}
+
+ personal = sys.modules["functions_personal_workflows"]
+ monkeypatch.setattr(personal, "get_personal_workflow", lookup, raising=False)
+ monkeypatch.setattr(personal, "get_personal_workflow_run", run_lookup, raising=False)
+ monkeypatch.setitem(sys.modules, "functions_group_workflows", SimpleNamespace(
+ get_group_workflow=lookup, get_group_workflow_run=run_lookup,
+ ))
+ monkeypatch.setattr("functions_workflow_artifacts.workflow_runtime_store", lambda *args: state["store"])
+ monkeypatch.setattr("functions_workflow_runtime_store.workflow_runtime_store", lambda *args: state["store"])
+
+ route_namespace = {
+ "Response": Response, "ExitStack": ExitStack, "hashlib": hashlib, "os": os,
+ "mimetypes": mimetypes, "quote": quote, "secure_filename": secure_filename,
+ "CosmosResourceNotFoundError": CosmosResourceNotFoundError,
+ "cosmos_conversations_container": services.conversations, "cosmos_messages_container": services.messages,
+ "build_conversation_participation_context": conversation_access,
+ "assert_generated_file_approval_allows_download": services.module.assert_generated_file_approval_allows_download,
+ "assert_generated_chat_artifact_is_published_for_user": operations["assert_generated_chat_artifact_is_published_for_user"],
+ "assert_evidence_available": lambda *args: None,
+ "has_generated_artifact_source": artifact_sources.has_generated_artifact_source,
+ "download_blob_content": lambda *args: pytest.fail("Generic files must not use a whole-byte download."),
+ }
+ routes = load_functions("route_enhanced_citations.py", {
+ "_get_authorized_chat_artifact_message", "_serve_chat_artifact_download",
+ "_normalize_response_file_name", "_build_content_disposition", "_resolve_generated_artifact_file_name",
+ }, route_namespace)
+ monkeypatch.setitem(sys.modules, "route_enhanced_citations", SimpleNamespace(
+ _get_authorized_chat_artifact_message=routes["_get_authorized_chat_artifact_message"],
+ ))
+ monkeypatch.setitem(sys.modules, "functions_artifact_publication", services.module)
+
+ def bind(workflow, store):
+ state.update(workflow=workflow, store=store)
+
+ return SimpleNamespace(
+ publication=services, blobs=blobs, state=state, bind=bind, operations=operations,
+ download=routes["_serve_chat_artifact_download"], settings=settings,
+ )
+
+
+@pytest.fixture
+def saved_output_artifact(artifact_services, monkeypatch, request):
+ options = getattr(request, "param", None) or {}
+ definition = deepcopy(options.get("definition") or loop_definition())
+ if options.get("group"):
+ definition["group_id"] = "workflow-group"
+ workflow, store, container, clock = loop_runtime(monkeypatch, definition=definition)
+ if options.get("storage") == "blob":
+ results = FakeBlobService()
+ configured = lambda *args, **kwargs: WorkflowResultStore(container, results, "workflow-results")
+ monkeypatch.setattr("functions_workflow_result_store._configured_store", configured)
+ monkeypatch.setattr("functions_workflow_result_store._configured_result_store", configured)
+ artifact_services.bind(workflow, store)
+ rows = options.get("rows", [
+ {"index": 0, "value": {"flag": False, "unicode": "\u03bb"}},
+ {"index": 1, "middle-only": "exact-record-not-a-preview"},
+ {"index": 0, "value": {"flag": False, "unicode": "\u03bb"}},
+ ])
+ # One admitted input may produce any complete output count within the existing byte quota.
+ flow, calls = execute_loop(
+ workflow, store, options.get("inputs", [{"seed": 1}]), result_for_item=lambda item: rows,
+ )
+ receipt = flow.final_outputs[0]
+ if options.get("source_node"):
+ receipt = receipt_for_node(workflow, store, options["source_node"])
+
+ def materialize(**kwargs):
+ with WorkflowRuntimeLease(store, owner_id="file-worker") as lease:
+ execution = StructuredWorkflowExecution(store, lease, workflow, "run", settings=artifact_services.settings)
+ with workflow_execution_scope(execution):
+ return materialize_workflow_saved_output(
+ execution, receipt, actor_user_id="owner", conversation_id="conversation-1", **kwargs,
+ )
+
+ return SimpleNamespace(
+ services=artifact_services, workflow=workflow, store=store, container=container,
+ rows=rows, receipt=receipt, materialize=materialize, calls=calls, clock=clock,
+ )
+
+
+def receipt_for_node(workflow, store, node_id):
+ saved = store.journal_read("attempt", [workflow_execution_id(workflow, "run", node_id), 1])["payload"]["workflow_result"]
+ return open_workflow_record_input(
+ workflow, "run", saved["producer"], saved["result_ref"], output_name="records", inspection=True,
+ ).receipt
+
+
+def partial_definition():
+ definition = loop_definition()
+ loop = definition["flow"]["nodes"][1]
+ loop["body"]["nodes"][0]["run_when"] = {
+ "op": "lt", "left": {"input": "item", "path": "/index"}, "right": {"literal": 1},
+ }
+ loop["body"]["outputs"][0]["required"] = False
+ definition["flow"]["nodes"][2]["output_contract"].update(allow_partial=True, require_complete_coverage=False)
+ definition["flow"]["outputs"][0]["allow_partial"] = True
+ return definition
+
+
+@pytest.mark.parametrize("saved_output_artifact", [{"storage": value} for value in ("cosmos", "blob")], indirect=True)
+def test_collect_is_a_truthful_authorized_file_and_downloads_exact_bytes(saved_output_artifact):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ artifact = fixture.materialize()
+ binding = artifact["source_binding"]
+ assert "task_id" not in binding["producer"]
+ assert binding["producer"]["node_id"] == "collect"
+ context = load_workflow_artifact_binding("owner", binding, for_publication=True)
+ assert context["source"].reader.manifest["analysis_origin"] is False
+ assert binding["source_receipt"]["result_ref"]["storage"] in {"cosmos", "blob"}
+ message = services.publication.messages.records[artifact["artifact_message_id"]]
+ assert "analysis_producer" not in message["metadata"]
+ assert "analysis_result_required" not in message["metadata"]
+ assert "generated_artifact_run_id" not in message["metadata"]
+ expected = json.dumps(fixture.rows, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii")
+ response = services.download("owner", "conversation-1", artifact["artifact_message_id"])
+ try:
+ assert response.get_data() == expected
+ assert response.headers["Cache-Control"] == "private, no-store"
+ assert response.headers["X-Content-Type-Options"] == "nosniff"
+ assert response.headers["Content-Disposition"].startswith("attachment;")
+ assert int(response.headers["Content-Length"]) == len(expected)
+ finally:
+ response.close()
+ assert artifact["content_sha256"] == hashlib.sha256(expected).hexdigest()
+ assert fixture.materialize() == artifact
+ assert services.blobs.writes == 1
+ assert len(fixture.calls) == 2
+
+
+@pytest.mark.parametrize("boundary", ["prepared_committed", "uploaded", "message_created", "ready_committed"])
+def test_lost_acknowledgements_reuse_the_prepared_bytes_and_address(saved_output_artifact, monkeypatch, boundary):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ if boundary == "uploaded":
+ services.blobs.fail_after_upload = True
+ elif boundary == "message_created":
+ original = services.publication.messages.create_item
+
+ def lose_message_ack(body):
+ result = original(body)
+ monkeypatch.setattr(services.publication.messages, "create_item", original)
+ raise TimeoutError("closed-fixture lost message acknowledgement")
+
+ monkeypatch.setattr(services.publication.messages, "create_item", lose_message_ack)
+ else:
+ original = fixture.store.journal_commit
+
+ def lose_ready_ack(token, kind, key, payload, **kwargs):
+ result = original(token, kind, key, payload, **kwargs)
+ expected_key = "generated-file-prepare" if boundary == "prepared_committed" else "generated-file-ready"
+ if key[0] == expected_key:
+ monkeypatch.setattr(fixture.store, "journal_commit", original)
+ raise TimeoutError("closed-fixture lost journal acknowledgement")
+ return result
+
+ monkeypatch.setattr(fixture.store, "journal_commit", lose_ready_ack)
+ with pytest.raises(TimeoutError):
+ fixture.materialize()
+ artifact = fixture.materialize()
+ assert services.blobs.writes == 1
+ assert load_workflow_artifact_binding("owner", artifact["source_binding"])["descriptor"]["content_sha256"] == artifact["content_sha256"]
+
+
+def test_uploaded_but_uncommitted_file_is_not_readable(saved_output_artifact, monkeypatch):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ original = fixture.store.journal_commit
+
+ def stop_before_ready(token, kind, key, payload, **kwargs):
+ if key[0] == "generated-file-ready":
+ raise TimeoutError("closed-fixture no ready checkpoint")
+ return original(token, kind, key, payload, **kwargs)
+
+ monkeypatch.setattr(fixture.store, "journal_commit", stop_before_ready)
+ with pytest.raises(TimeoutError):
+ fixture.materialize()
+ message = next(value for value in services.publication.messages.records.values()
+ if value.get("metadata", {}).get("generated_artifact_source_required"))
+ with pytest.raises(AnalysisResultUnavailable):
+ services.download("owner", "conversation-1", message["id"])
+ with pytest.raises(AnalysisResultUnavailable):
+ services.publication.module._authorize_artifact("owner", "conversation-1", message["id"])
+
+
+@pytest.mark.parametrize("change", ["workflow", "conversation", "approval", "source_binding", "descriptor", "bytes"])
+def test_revocation_and_binding_corruption_fail_closed(saved_output_artifact, change):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ artifact = fixture.materialize()
+ message = services.publication.messages.records[artifact["artifact_message_id"]]
+ if change == "workflow":
+ services.state["workflow_allowed"] = False
+ elif change == "conversation":
+ services.state["conversation_allowed"] = False
+ elif change == "approval":
+ services.publication.state["artifact_approved"] = False
+ elif change == "source_binding":
+ message["metadata"].pop("generated_artifact_source")
+ elif change == "descriptor":
+ message["metadata"]["generated_artifact_source"]["materialization"]["descriptor_ref"]["sha256"] = "0" * 64
+ else:
+ services.blobs.data[(artifact["blob_container"], artifact["blob_path"])] = b"[]"
+ with pytest.raises((AnalysisResultUnavailable, PermissionError, ValueError)):
+ services.download("owner", "conversation-1", artifact["artifact_message_id"])
+
+
+def test_revocation_during_download_never_returns_an_authorized_prefix(saved_output_artifact):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ artifact = fixture.materialize()
+ services.blobs.read_hook = lambda: services.state.update(workflow_allowed=False)
+ with pytest.raises(AnalysisResultUnavailable):
+ services.download("owner", "conversation-1", artifact["artifact_message_id"])
+
+
+def test_new_file_identity_is_not_an_authorization_grant(saved_output_artifact):
+ fixture = saved_output_artifact
+ artifact = fixture.materialize()
+ binding = artifact["source_binding"]
+ with pytest.raises(AnalysisResultUnavailable):
+ load_workflow_artifact_binding("other-user", binding)
+ for field, changed in (("attempt", 2), ("execution_id", "0" * 64), ("node_id", "source-node"), ("task_id", "invented")):
+ bad = deepcopy(binding)
+ bad["producer"][field] = changed
+ bad["source_receipt"]["producer"][field] = changed
+ with pytest.raises((AnalysisResultUnavailable, ValueError)):
+ load_workflow_artifact_binding("owner", bad)
+
+
+def test_queue_copies_a_stream_not_its_python_representation(artifact_services):
+ queued = []
+
+ def queue(**kwargs):
+ path = kwargs["temp_file_path"]
+ try:
+ with open(path, "rb") as content:
+ queued.append(content.read())
+ finally:
+ os.remove(path)
+
+ artifact_services.operations["_queue_document_upload_background_task"] = queue
+ content = b'[{"actual":"stream bytes"}]'
+ artifact_services.operations["queue_generated_document_processing"](
+ "document", "owner", "records.json", io.BytesIO(content),
+ )
+ assert queued == [content]
+
+
+@pytest.mark.parametrize("saved_output_artifact", [
+ {"storage": storage, "rows": rows}
+ for storage in ("cosmos", "blob")
+ for rows in ([], [{"index": index} for index in range(501)],
+ [{"index": index, "payload": "x" * 8192} for index in range(1100)])
+], indirect=True)
+def test_real_collections_export_empty_large_and_more_records_than_the_input_limit(saved_output_artifact):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ artifact = fixture.materialize()
+ content = services.blobs.data[(artifact["blob_container"], artifact["blob_path"])]
+ assert json.loads(content) == fixture.rows
+ assert artifact["record_count"] == len(fixture.rows)
+ if len(fixture.rows) == 1100:
+ assert len(content) > 8 * 1024 * 1024
+ assert len([call for call in fixture.calls if call[0] == "body"]) == 1
+ assert services.blobs.writes == 1
+ assert fixture.materialize() == artifact
+
+
+@pytest.mark.parametrize("saved_output_artifact", [{"source_node": "source-node"}], indirect=True)
+def test_real_task_records_use_the_same_adapter_with_their_actual_task_identity(saved_output_artifact):
+ fixture = saved_output_artifact
+ artifact = fixture.materialize()
+ assert artifact["source_binding"]["producer"]["task_id"] == "source"
+ assert artifact["source_binding"]["producer"]["node_id"] == "source-node"
+ assert json.loads(fixture.services.blobs.data[(artifact["blob_container"], artifact["blob_path"])]) == [{"seed": 1}]
+
+
+@pytest.mark.parametrize("saved_output_artifact", [
+ {"definition": partial_definition(), "inputs": [{"seed": 1}, {"seed": 2}]},
+], indirect=True)
+def test_partial_collect_needs_explicit_acceptance_and_retains_coverage(saved_output_artifact):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ with pytest.raises(ValueError):
+ fixture.materialize()
+ assert services.blobs.writes == 0
+ artifact = fixture.materialize(allow_partial=True)
+ assert artifact["validation_status"] == "accepted_partial"
+ source = load_workflow_artifact_binding("owner", artifact["source_binding"], for_publication=True)["source"]
+ assert source.reader.manifest["coverage"]["skipped_count"] == 1
+ assert list(source.iter_records()) == fixture.rows
+ message = services.publication.messages.records[artifact["artifact_message_id"]]
+ assert "accepted_partial" in message["metadata"]["generated_artifact_summary"]
+
+
+def test_duplicate_business_keys_remain_saved_but_are_never_exported(artifact_services, monkeypatch):
+ definition = loop_definition()
+ definition["flow"]["nodes"][2]["output_contract"]["identity_field"] = "business_id"
+ workflow, store, _, _ = loop_runtime(monkeypatch, definition=definition)
+ artifact_services.bind(workflow, store)
+ with pytest.raises(WorkflowInputError):
+ execute_loop(workflow, store, [{"business_id": "same"}, {"business_id": "same"}])
+ receipt = receipt_for_node(workflow, store, "collect")
+ for accepted_partial in (False, True):
+ with WorkflowRuntimeLease(store, owner_id="file-worker") as lease:
+ execution = StructuredWorkflowExecution(store, lease, workflow, "run")
+ with workflow_execution_scope(execution), pytest.raises((ValueError, AnalysisResultUnavailable)):
+ materialize_workflow_saved_output(
+ execution, receipt, actor_user_id="owner", conversation_id="conversation-1",
+ allow_partial=accepted_partial,
+ )
+ original = open_workflow_record_input(
+ workflow, "run", receipt["producer"], receipt["result_ref"], output_name="records", inspection=True,
+ )
+ assert list(original.iter_records()) == [{"business_id": "same"}, {"business_id": "same"}]
+ assert artifact_services.blobs.writes == 0
+
+
+@pytest.mark.parametrize("extra", [0, 1])
+def test_real_export_quota_is_exact_and_never_publishes_a_prefix(artifact_services, monkeypatch, extra):
+ rows = [{"value": "x" * 8177} for _ in range(128)]
+ encoded = lambda: json.dumps(rows, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii")
+ rows[-1]["value"] += "x" * (1024 * 1024 - len(encoded()) + extra)
+ workflow, store, _, _ = loop_runtime(monkeypatch)
+ artifact_services.bind(workflow, store)
+ flow, _ = execute_loop(workflow, store, [{"seed": 1}], result_for_item=lambda item: rows)
+ receipt = flow.final_outputs[0]
+ artifact_services.settings["max_generated_chat_artifact_size_mb"] = 1
+ with WorkflowRuntimeLease(store, owner_id="file-worker") as lease:
+ execution = StructuredWorkflowExecution(store, lease, workflow, "run", settings=artifact_services.settings)
+ with workflow_execution_scope(execution):
+ if extra:
+ with pytest.raises(ValueError, match="size limit"):
+ materialize_workflow_saved_output(
+ execution, receipt, actor_user_id="owner", conversation_id="conversation-1",
+ )
+ assert artifact_services.blobs.writes == 0
+ else:
+ artifact = materialize_workflow_saved_output(
+ execution, receipt, actor_user_id="owner", conversation_id="conversation-1",
+ )
+ assert artifact_services.blobs.data[(artifact["blob_container"], artifact["blob_path"])] == encoded()
+ reader = open_workflow_record_input(workflow, "run", receipt["producer"], receipt["result_ref"], output_name="records")
+ assert list(reader.iter_records()) == rows
+ assert artifact_services.publication.calls["create"] == []
+
+
+@pytest.mark.parametrize("saved_output_artifact", [{"group": True}], indirect=True)
+@pytest.mark.parametrize("revocation", ["membership", "group_status", "conversation"])
+def test_group_source_scope_and_conversation_are_independent_current_grants(saved_output_artifact, revocation):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ artifact = fixture.materialize()
+ assert artifact["source_binding"]["scope"] == {"type": "group", "id": "workflow-group"}
+ assert load_workflow_artifact_binding("owner", artifact["source_binding"])["source"].record_count == len(fixture.rows)
+ if revocation == "membership":
+ services.publication.state["group_role"] = None
+ elif revocation == "group_status":
+ services.publication.state["workspace_status"] = "inactive"
+ else:
+ services.state["conversation_allowed"] = False
+ with pytest.raises((PermissionError, AnalysisResultUnavailable)):
+ services.download("owner", "conversation-1", artifact["artifact_message_id"])
+
+
+@pytest.mark.parametrize("saved_output_artifact", [{"rows": [{"index": index} for index in range(501)]}], indirect=True)
+def test_workflow_revocation_during_serialization_stops_before_upload(saved_output_artifact, monkeypatch):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ original = WorkflowRecordExportSource.iter_records
+ read = []
+
+ def revoke(source):
+ for record in original(source):
+ read.append(record)
+ if len(read) == 100:
+ services.state["workflow_allowed"] = False
+ yield record
+
+ monkeypatch.setattr(WorkflowRecordExportSource, "iter_records", revoke)
+ with pytest.raises(AnalysisResultUnavailable):
+ fixture.materialize()
+ assert len(read) == 100 and services.blobs.writes == 0
+
+
+def test_tombstoned_workflow_never_resurrects_its_saved_file(saved_output_artifact):
+ fixture = saved_output_artifact
+ artifact = fixture.materialize()
+ fixture.store.tombstone()
+ with pytest.raises(AnalysisResultUnavailable):
+ fixture.services.download("owner", "conversation-1", artifact["artifact_message_id"])
+ assert fixture.services.blobs.writes == 1
+
+
+@pytest.mark.parametrize("target", ["source", "descriptor", "prepare", "ready", "attempt"])
+def test_missing_bound_data_never_falls_back_to_previews_or_other_attempts(saved_output_artifact, target):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ artifact = fixture.materialize()
+ if target in {"source", "descriptor"}:
+ reference = {
+ "source": fixture.receipt["result_ref"],
+ "descriptor": artifact["source_binding"]["materialization"]["descriptor_ref"],
+ }[target]
+ key = next(key for key, row in fixture.container.items.items()
+ if row.get("type") == "workflow_result_chunk" and row.get("sha256") == reference["sha256"])
+ else:
+ kind = "attempt" if target == "attempt" else "unit"
+ unit = (
+ [fixture.receipt["producer"]["execution_id"], fixture.receipt["producer"]["attempt"]]
+ if target == "attempt" else [f"generated-file-{target}", artifact["source_binding"]["export_key"]]
+ )
+ row = fixture.store.journal_read(kind, unit)
+ key = ("run", row["id"])
+ del fixture.container.items[key]
+ with pytest.raises((AnalysisResultUnavailable, LookupError, ValueError)):
+ services.download("owner", "conversation-1", artifact["artifact_message_id"])
+ assert services.blobs.writes == 1
+
+
+def test_missing_collection_index_cannot_become_a_file(saved_output_artifact):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ reference = fixture.receipt["output_ref"]
+ key = next(key for key, row in fixture.container.items.items()
+ if row.get("type") == "workflow_result_chunk" and row.get("sha256") == reference["sha256"])
+ del fixture.container.items[key]
+ with pytest.raises((AnalysisResultUnavailable, ValueError, CosmosResourceNotFoundError)):
+ fixture.materialize()
+ assert services.blobs.writes == 0
+
+
+def test_changed_unacknowledged_blob_is_never_overwritten(saved_output_artifact):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ services.blobs.fail_after_upload = True
+ with pytest.raises(TimeoutError):
+ fixture.materialize()
+ key = next(iter(services.blobs.data))
+ services.blobs.data[key] = b"changed"
+ with pytest.raises(ValueError, match="different bytes"):
+ fixture.materialize()
+ assert services.blobs.writes == 1 and services.blobs.data[key] == b"changed"
+ assert not any(row.get("metadata", {}).get("generated_artifact_source_required")
+ for row in services.publication.messages.records.values())
+
+
+def test_deleted_file_message_is_not_recreated_by_ready_checkpoint_replay(saved_output_artifact):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ artifact = fixture.materialize()
+ del services.publication.messages.records[artifact["artifact_message_id"]]
+ assert fixture.materialize() == artifact
+ with pytest.raises(LookupError):
+ services.download("owner", "conversation-1", artifact["artifact_message_id"])
+ assert services.blobs.writes == 1
+ assert artifact["artifact_message_id"] not in services.publication.messages.records
+
+
+def history_messages(services, messages):
+ history = load_functions("content_screening\\access.py", {"public_history_messages"}, {
+ "Mapping": Mapping, "deepcopy": deepcopy, "import_module": import_module,
+ "_current_user_id": lambda user: user,
+ "refresh_workspace_attachment": lambda message, user: message,
+ "assert_evidence_available": lambda *args, **kwargs: None,
+ "assert_current_request_sources_available": lambda *args: None,
+ "ScreeningError": DocumentHeldError,
+ })["public_history_messages"]
+ return history(messages, "owner")
+
+
+def test_history_and_message_export_keep_only_authorized_public_file_metadata(saved_output_artifact):
+ fixture, services = saved_output_artifact, saved_output_artifact.services
+ artifact = fixture.materialize()
+ message = deepcopy(services.publication.messages.records[artifact["artifact_message_id"]])
+ message.update(file_content="PRIVATE_PREVIEW_CANARY", extracted_text="PRIVATE_PREVIEW_CANARY")
+ card = {
+ "capability": "file_export", "source_kind": "workflow_saved_output",
+ "conversation_id": "conversation-1", "artifact_message_id": artifact["artifact_message_id"],
+ "file_name": artifact["file_name"], "output_format": "json", "row_source": "saved_records",
+ "row_count": len(fixture.rows), "storage_scope": "chat",
+ "blob_path": artifact["blob_path"], "source_binding": artifact["source_binding"],
+ "preview": "PRIVATE_PREVIEW_CANARY",
+ }
+ assistant = {"id": "reply", "conversation_id": "conversation-1", "role": "assistant",
+ "content": "Publication recorded.", "metadata": {"generated_tabular_outputs": [card]}}
+ safe = history_messages(services, [message, assistant])
+ encoded = json.dumps(safe)
+ for private in ("PRIVATE_PREVIEW_CANARY", "generated_artifact_source", "source_binding", "blob_path",
+ "descriptor_ref", "iteration_path"):
+ assert private not in encoded
+ assert safe[1]["metadata"]["generated_tabular_outputs"][0]["row_count"] == len(fixture.rows)
+ export = load_functions("route_backend_conversation_export.py", {"_load_export_message_for_user"}, {
+ "Dict": Dict, "Any": Any,
+ "cosmos_conversations_container": services.publication.conversations,
+ "cosmos_messages_container": services.publication.messages,
+ "analysis_result_contexts": saved_analysis.analysis_result_contexts,
+ "load_saved_analysis": lambda *args: pytest.fail("Collect must not load a fabricated Analyze context."),
+ "authorize_analysis_artifact": lambda *args: pytest.fail("The generic source must use its typed reader."),
+ "public_history_messages": lambda messages, user: history_messages(services, messages),
+ "DocumentHeldError": DocumentHeldError,
+ })["_load_export_message_for_user"]
+ exported = export("owner", "conversation-1", artifact["artifact_message_id"])
+ assert "generated_artifact_source" not in json.dumps(exported)
+ services.state["workflow_allowed"] = False
+ hidden = history_messages(services, [message, assistant])
+ assert hidden[0]["content_unavailable"]
+ unavailable = hidden[1]["metadata"]["generated_tabular_outputs"][0]
+ assert unavailable["status"] == "unavailable"
+ assert "artifact_message_id" not in unavailable and "preview" not in unavailable
+ with pytest.raises(AnalysisResultUnavailable):
+ export("owner", "conversation-1", artifact["artifact_message_id"])
+
+
+def test_withheld_card_restores_request_screening_context(saved_output_artifact, monkeypatch):
+ fixture = saved_output_artifact
+ artifact = fixture.materialize()
+ message = {
+ "id": "reply", "conversation_id": "conversation-1", "role": "assistant",
+ "metadata": {"generated_tabular_outputs": [{
+ "source_kind": "workflow_saved_output", "conversation_id": "conversation-1",
+ "artifact_message_id": artifact["artifact_message_id"],
+ }]},
+ }
+
+ def held(*args):
+ g.content_screening_error = DocumentHeldError()
+ g.content_screening_sources["withheld"] = {"unavailable": True}
+ raise g.content_screening_error
+
+ monkeypatch.setattr(sys.modules["route_enhanced_citations"], "_get_authorized_chat_artifact_message", held)
+ with Flask(__name__).test_request_context():
+ g.content_screening_error = None
+ g.content_screening_sources = {"existing": {"available": True}}
+ safe = artifact_sources.sanitize_generated_artifact_history(message, "owner")
+ assert safe["metadata"]["generated_tabular_outputs"][0]["status"] == "unavailable"
+ assert g.content_screening_error is None
+ assert g.content_screening_sources == {"existing": {"available": True}}
+
+
+if __name__ == "__main__":
+ raise SystemExit(pytest.main([__file__, "-q"]))
diff --git a/ui_tests/fixtures/workflow_saved_output_publication.py b/ui_tests/fixtures/workflow_saved_output_publication.py
new file mode 100644
index 000000000..63bc19edd
--- /dev/null
+++ b/ui_tests/fixtures/workflow_saved_output_publication.py
@@ -0,0 +1,181 @@
+# workflow_saved_output_publication.py
+"""
+Closed saved-output publication fixtures for the production V2 SPA.
+Version: 0.261.119
+Implemented in: 0.261.119
+
+Reuse production definition validation and the publication-completion boundary.
+All source records, run facts, artifact metadata, and download bytes are fictional.
+No workflow is executed and no document is published to a live workspace.
+"""
+
+import copy
+import json
+
+import pytest
+
+from ui_tests.fixtures.workflow_control_definitions import flow_binding
+from ui_tests.fixtures.workflow_editor import GROUP_ID, editor_options
+from ui_tests.fixtures.workflow_loops import loop_workflow_record, record_contract
+from ui_tests.fixtures.workflow_publication_completion import (
+ WorkflowPublicationFixture,
+ connect_options, # noqa: F401
+ execution_id,
+ publication_key,
+ publication_status,
+ publication_workflow_record,
+)
+
+
+SOURCE_CAPABILITIES = [
+ {
+ "source_kind": "native_analysis",
+ "output_kinds": ["records", "json", "text", "document_results"],
+ "artifact_formats": ["md", "csv", "json"],
+ },
+ {
+ "source_kind": "saved_output",
+ "output_kinds": ["records"],
+ "artifact_formats": ["json"],
+ },
+]
+ARTIFACT_CONVERSATION_ID = "saved-output-conversation"
+ARTIFACT_MESSAGE_ID = "saved-output-artifact"
+ARTIFACT_FILE_NAME = "workflow-output-fixture.json"
+ARTIFACT_BYTES = json.dumps([
+ {"values": {"finding": "Complete saved finding", "count": 0, "accepted": False, "optional": None},
+ "provenance": {"source": "fictional-first"}},
+ {"values": {"finding": "Unicode \u00e9 and nested values", "nested": [{"kept": True}]},
+ "provenance": {"source": "fictional-second"}},
+], sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False).encode("utf-8")
+
+
+def publication_task(record):
+ return next(task for task in record["tasks"] if task["id"] == "publish")
+
+
+def use_saved_output(record, producer="collect-findings", output="records"):
+ task = publication_task(record)
+ task["publication"].update(source_kind="saved_output", artifact_format="json")
+ task["inputs"] = [flow_binding("deliverable", producer, output, kind="records")]
+ return record
+
+
+def saved_output_workflow_record(scope="user"):
+ record = publication_workflow_record(scope)
+ loop_record = loop_workflow_record(group=scope == "group")
+ generic = {
+ "id": "records-task", "type": "instructions", "name": "Prepare saved records",
+ "instructions": "Produce exact records without a native Analyze artifact.",
+ "runner": {"type": "inherit"}, "inputs": [], "reference_ids": [],
+ "document_action": {"type": "none"}, "output_contract": record_contract(),
+ }
+ record["tasks"].extend([generic, *loop_record["tasks"]])
+ for index, task in enumerate(record["tasks"], 1):
+ task["order"] = index
+ analyze, publish = record["flow"]["nodes"]
+ record["flow"]["nodes"] = [
+ analyze,
+ {"id": "saved-records", "kind": "task", "task_id": generic["id"]},
+ *loop_record["flow"]["nodes"],
+ {
+ "id": "choose-output", "kind": "if", "inputs": [],
+ "condition": {"op": "eq", "left": {"literal": True}, "right": {"literal": True}},
+ "then": {"id": "collected-path", "nodes": []},
+ "else": {"id": "saved-path", "nodes": []},
+ "join": {
+ "id": "output-join",
+ "exports": [{
+ "name": "selected_records", "expected_kind": "records", "required": True,
+ "then": {"node_id": "collect-findings", "output": "records"},
+ "else": {"node_id": "saved-records", "output": "records"},
+ }],
+ },
+ },
+ publish,
+ ]
+ return record
+
+
+class WorkflowSavedOutputFixture(WorkflowPublicationFixture):
+ """Source capabilities and file reads on the existing closed workflow fixture."""
+
+ def __init__(self, page):
+ super().__init__(page)
+ self.publication_sources = copy.deepcopy(SOURCE_CAPABILITIES)
+ self.option_overrides = {}
+ self.artifact_downloads = []
+ self.artifact_bytes = ARTIFACT_BYTES
+ for scope in ("user", "group"):
+ record = saved_output_workflow_record(scope)
+ records = self.group_workflows[GROUP_ID] if scope == "group" else self.personal_workflows
+ records[record["id"]] = record
+ self.conversations = [{
+ "id": ARTIFACT_CONVERSATION_ID, "title": "Saved workflow files",
+ "last_updated": "2026-09-18T12:05:00Z",
+ }]
+ self.messages = {
+ ARTIFACT_CONVERSATION_ID: [{
+ "id": "saved-output-reply", "conversation_id": ARTIFACT_CONVERSATION_ID,
+ "role": "assistant", "content": "Saved workflow output is available.",
+ "metadata": {
+ "generated_tabular_outputs": [{
+ "capability": "file_export",
+ "source_kind": "workflow_saved_output",
+ "artifact_message_id": ARTIFACT_MESSAGE_ID,
+ "conversation_id": ARTIFACT_CONVERSATION_ID,
+ "storage_scope": "chat",
+ "output_format": "json",
+ "file_name": ARTIFACT_FILE_NAME,
+ "row_count": 2,
+ "row_source": "saved_records",
+ "summary": "2 exact saved records (valid)",
+ }],
+ },
+ }],
+ }
+
+ 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)
+ options.update(
+ supported_node_kinds=["task", "if", "route", "for_each", "collect"],
+ supported_iterable_kinds=["input", "documents", "workspace_query"],
+ supported_query_modes=["all_matches", "best_n"],
+ supported_binding_sources=["node_output", "loop_item"],
+ )
+ options["flow_limits"]["max_loop_items"] = 500
+ for runner in [*options["agents"], *options["models"], options["default_model"]]:
+ runner["loop_eligible"] = True
+ if self.publication_policies is not None:
+ options["supported_publication_completion_policies"] = copy.deepcopy(self.publication_policies)
+ if self.publication_sources is not None:
+ options["publication_source_capabilities"] = copy.deepcopy(self.publication_sources)
+ options.update(copy.deepcopy(self.option_overrides))
+ self._json(route, options)
+ elif entry.path == "/api/v2/orchestration/runs":
+ assert entry.method == "GET", entry
+ assert entry.query == {"conversation_id": [ARTIFACT_CONVERSATION_ID], "limit": ["25"]}, entry
+ self._json(route, {"runs": []})
+ elif entry.path == "/api/chat_artifacts/download":
+ assert entry.method == "GET", entry
+ assert entry.query == {
+ "conversation_id": [ARTIFACT_CONVERSATION_ID], "message_id": [ARTIFACT_MESSAGE_ID],
+ }, entry
+ self.artifact_downloads.append(entry)
+ route.fulfill(
+ status=200, content_type="application/json", body=self.artifact_bytes,
+ headers={"Content-Disposition": f'attachment; filename="{ARTIFACT_FILE_NAME}"'},
+ )
+ else:
+ super()._dispatch(route, entry)
+
+
+@pytest.fixture
+def saved_output_ui(page):
+ fixture = WorkflowSavedOutputFixture(page)
+ yield fixture
+ fixture.assert_clean()
diff --git a/ui_tests/test_v2_workflow_saved_output_publication.py b/ui_tests/test_v2_workflow_saved_output_publication.py
new file mode 100644
index 000000000..3683d7748
--- /dev/null
+++ b/ui_tests/test_v2_workflow_saved_output_publication.py
@@ -0,0 +1,598 @@
+# test_v2_workflow_saved_output_publication.py
+"""
+Closed V2 List coverage for exact saved-record file publication.
+Version: 0.261.119
+Implemented in: 0.261.119
+
+The real production SPA saves through production definition validation. Fictional
+HTTP fixtures exercise scoped authoring, capability fallback, safe readback,
+keyboard/mobile controls, and the existing authorized generated-file download.
+No model, live workflow, or workspace publication is invoked.
+"""
+
+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 closed fixtures configure repository-local, isolated application imports.
+from ui_tests.fixtures.workflow_saved_output_publication import (
+ ARTIFACT_BYTES,
+ ARTIFACT_CONVERSATION_ID,
+ ARTIFACT_FILE_NAME,
+ GROUP_ID,
+ SOURCE_CAPABILITIES,
+ connect_options, # noqa: F401
+ execution_id,
+ publication_key,
+ publication_status,
+ publication_task,
+ saved_output_ui, # noqa: F401
+ use_saved_output,
+)
+
+
+pytestmark = pytest.mark.ui
+
+
+def saved_workflow(ui, scope="user"):
+ workflow_id = publication_key(scope)[1]
+ records = ui.group_workflows[GROUP_ID] if scope == "group" else ui.personal_workflows
+ return records[workflow_id]
+
+
+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 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 source_field(block):
+ return block.get_by_label("Publication source for Publish artifact", exact=True)
+
+
+def format_field(block):
+ return block.get_by_label("Publication format for Publish artifact", exact=True)
+
+
+def input_field(block, name, index=1):
+ return block.get_by_label(f"Publish artifact inputs input {index} {name}", exact=True)
+
+
+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 reopen_editor(ui, scope="user"):
+ ui.page.get_by_role("button", name=f"Edit {saved_workflow(ui, scope)['name']}", exact=True).click()
+ return publication_fields(ui.page)
+
+
+def assert_no_execution(ui):
+ assert not any("/runtime/" in request.path or request.path.endswith(("/run", "/publish", "/promote"))
+ for request in ui.writes)
+
+
+@pytest.mark.parametrize("scope", ["user", "group"])
+@pytest.mark.parametrize("source,producer,output", [
+ ("native_analysis", "analyze", "authoritative"),
+ ("saved_output", "collect-findings", "records"),
+ ("saved_output", "output-join", "selected_records"),
+ ("saved_output", "saved-records", "authoritative"),
+])
+def test_both_sources_save_reopen_with_exact_records_producers(saved_output_ui, scope, source, producer, output):
+ ui, page = saved_output_ui, saved_output_ui.page
+ original = copy.deepcopy(saved_workflow(ui, scope))
+ block = open_editor(ui, scope)
+ source_field(block).select_option(source)
+ input_field(block, "producer").select_option(producer)
+ input_field(block, "output").select_option(output)
+ input_field(block, "name").fill("deliverable")
+ policy_field(block).select_option("approved")
+ expected_format = "json" if source == "saved_output" else "csv"
+ format_field(block).select_option(expected_format)
+ for destination, identifier in (("public", "public-handbook"), ("group", GROUP_ID), ("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(source_field(block)).to_have_value(source)
+ expect(policy_field(block)).to_have_value("approved")
+ 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)
+ if source == "saved_output":
+ expect(format_field(block).locator("option:checked")).to_have_text("JSON - exact saved records")
+ expect(format_field(block).locator('option[value="md"]')).to_have_attribute("disabled", "")
+ expect(format_field(block).locator('option[value="csv"]')).to_have_attribute("disabled", "")
+ expect(input_field(block, "producer").locator('option[value="analyze"]')).to_have_count(0)
+ expect(input_field(block, "kind")).to_have_value("records")
+ expect(block.get_by_text("without rerunning analysis.", exact=False)).to_be_visible()
+ expect(block.get_by_role("button", name="Add publish artifact inputs input", exact=True)).to_be_disabled()
+ write = save_editor(ui)
+ task = publication_task(write.body)
+ assert task["publication"] == {
+ "source_kind": source, "artifact_format": expected_format,
+ "workspace_scope": "group" if scope == "group" else "personal",
+ "completion_policy": "approved", **({"group_id": GROUP_ID} if scope == "group" else {}),
+ }
+ assert task["inputs"] == [{
+ "name": "deliverable",
+ "source": {"kind": "node_output", "node_id": producer, "output": output, "scope": "current"},
+ "required": True, "expected_kind": "records" if source == "saved_output" else "json",
+ "allow_partial": False,
+ }]
+ assert task["runner"]["type"] == "inherit" and not task["runner"].get("model_id")
+ assert task["document_action"] == {"type": "none"}
+ assert write.query.get("group_id") == ([GROUP_ID] if scope == "group" else None)
+ assert write.body["flow"] == original["flow"]
+ block = reopen_editor(ui, scope)
+ expect(source_field(block)).to_have_value(source)
+ expect(format_field(block)).to_have_value(expected_format)
+ expect(policy_field(block)).to_have_value("approved")
+ expect(input_field(block, "producer")).to_have_value(producer)
+ expect(input_field(block, "output")).to_have_value(output)
+ reopened_task = publication_task(save_editor(ui).body)
+ assert reopened_task["publication"] == task["publication"]
+ assert reopened_task["inputs"] == task["inputs"]
+ assert_no_execution(ui)
+
+
+@pytest.mark.parametrize("scope", ["user", "group"])
+@pytest.mark.parametrize("advertised", [True, False])
+def test_omitted_source_and_policy_stay_omitted_with_native_controls(saved_output_ui, scope, advertised):
+ ui = saved_output_ui
+ publication = publication_task(saved_workflow(ui, scope))["publication"]
+ publication.pop("completion_policy")
+ if not advertised:
+ ui.publication_sources = None
+ ui.publication_policies = None
+ block = open_editor(ui, scope)
+ expect(source_field(block)).to_have_value("native_analysis")
+ expect(policy_field(block)).to_have_value("")
+ if not advertised:
+ expect(source_field(block).locator('option[value="saved_output"]')).to_have_attribute("disabled", "")
+ expect(policy_field(block)).to_be_disabled()
+ expect(block.get_by_text("This server does not advertise", exact=False)).to_be_visible()
+ for value in ("json", "md", "csv"):
+ format_field(block).select_option(value)
+ expect(format_field(block)).to_have_value(value)
+ block.get_by_label("Instructions", exact=True).fill("Preserve the existing source behavior.")
+ expected = {**publication, "artifact_format": "csv"}
+ assert publication_task(save_editor(ui).body)["publication"] == expected
+ block = reopen_editor(ui, scope)
+ expect(source_field(block)).to_have_value("native_analysis")
+ expect(policy_field(block)).to_have_value("")
+ assert publication_task(save_editor(ui).body)["publication"] == expected
+
+
+def test_explicit_native_source_remains_supported_by_an_older_server(saved_output_ui):
+ ui = saved_output_ui
+ task = publication_task(saved_workflow(ui))
+ task["publication"]["source_kind"] = "native_analysis"
+ task["publication"].pop("completion_policy")
+ original = copy.deepcopy(task["publication"])
+ ui.publication_sources = None
+ ui.publication_policies = None
+ block = open_editor(ui)
+ expect(source_field(block)).to_have_value("native_analysis")
+ block.get_by_label("Instructions", exact=True).fill("Keep the explicit native Analyze source.")
+ assert publication_task(save_editor(ui).body)["publication"] == original
+
+
+def test_future_capabilities_never_enable_unimplemented_saved_formats(saved_output_ui):
+ ui = saved_output_ui
+ ui.publication_sources.extend([{
+ "source_kind": "future_source", "output_kinds": ["records"], "artifact_formats": ["pdf"],
+ }])
+ ui.publication_sources[1]["artifact_formats"].extend(["csv", "md", "pdf", "docx", "pptx"])
+ block = open_editor(ui)
+ expect(source_field(block).locator('option[value="future_source"]')).to_have_count(0)
+ source_field(block).select_option("saved_output")
+ expect(format_field(block).locator("option:enabled")).to_have_count(1)
+ expect(format_field(block).locator("option:enabled")).to_have_text("JSON - exact saved records")
+ input_field(block, "producer").select_option("saved-records")
+ assert publication_task(save_editor(ui).body)["publication"]["artifact_format"] == "json"
+
+
+def test_switching_back_to_native_keeps_completion_and_requires_explicit_native_input(saved_output_ui):
+ ui = saved_output_ui
+ record = use_saved_output(saved_workflow(ui))
+ original_flow = copy.deepcopy(record["flow"])
+ block = open_editor(ui)
+ policy_field(block).select_option("approved")
+ input_field(block, "allow partial").check()
+ source_field(block).select_option("native_analysis")
+ expect(policy_field(block)).to_have_value("approved")
+ expect(input_field(block, "producer")).to_have_value("collect-findings")
+ expect(input_field(block, "allow partial")).to_be_checked()
+ expect(format_field(block).locator("option:enabled")).to_have_count(3)
+ input_field(block, "producer").select_option("analyze")
+ format_field(block).select_option("md")
+ task = publication_task(save_editor(ui).body)
+ assert task["publication"]["source_kind"] == "native_analysis"
+ assert task["publication"]["completion_policy"] == "approved"
+ assert task["inputs"][0]["source"]["node_id"] == "analyze"
+ assert task["inputs"][0]["allow_partial"] is True
+ assert saved_workflow(ui)["flow"] == original_flow
+
+
+@pytest.mark.parametrize("policies", [["submitted", "approved", "indexed_ready"], None, ["approved"]])
+def test_enabling_publication_never_defaults_to_saved_output_or_invents_a_policy(saved_output_ui, policies):
+ ui = saved_output_ui
+ publication_task(saved_workflow(ui)).pop("publication")
+ ui.publication_policies = policies
+ block = open_editor(ui)
+ block.get_by_text("Publish a workflow file", exact=True).click()
+ expect(source_field(block)).to_have_value("native_analysis")
+ expect(format_field(block)).to_have_value("md")
+ expected_policy = "submitted" if policies and "submitted" in policies else ""
+ expect(policy_field(block)).to_have_value(expected_policy)
+ source_field(block).select_option("saved_output")
+ input_field(block, "producer").select_option("collect-findings")
+ input_field(block, "output").select_option("records")
+ publication = publication_task(save_editor(ui).body)["publication"]
+ assert publication["source_kind"] == "saved_output"
+ assert publication["artifact_format"] == "json"
+ if expected_policy:
+ assert publication["completion_policy"] == expected_policy
+ else:
+ assert "completion_policy" not in publication
+
+
+@pytest.mark.parametrize("capabilities", [
+ None,
+ [],
+ [SOURCE_CAPABILITIES[0]],
+ [{"source_kind": "saved_output", "output_kinds": ["json"], "artifact_formats": ["json"]}],
+ [{"source_kind": "saved_output", "output_kinds": ["records"], "artifact_formats": ["csv"]}],
+])
+def test_saved_source_without_advertised_profile_stays_read_only(saved_output_ui, capabilities):
+ ui, page = saved_output_ui, saved_output_ui.page
+ record = use_saved_output(saved_workflow(ui))
+ original = copy.deepcopy(record)
+ ui.publication_sources = capabilities
+ block = open_editor(ui)
+ expect(page.get_by_role("alert").filter(has_text="does not support the saved workflow output")).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()
+ expect(source_field(block)).to_have_value("saved_output")
+ expect(format_field(block)).to_have_value("json")
+ expect(format_field(block)).to_be_disabled()
+ assert record["tasks"] == original["tasks"] and record["flow"] == original["flow"]
+ assert not ui.workflow_writes
+
+
+@pytest.mark.parametrize("source", [None, "", "future_source", {"kind": "saved_output"}, ["saved_output"]])
+def test_unknown_saved_source_is_preserved_not_coerced(saved_output_ui, source):
+ ui, page = saved_output_ui, saved_output_ui.page
+ record = use_saved_output(saved_workflow(ui))
+ publication_task(record)["publication"]["source_kind"] = copy.deepcopy(source)
+ original = copy.deepcopy(publication_task(record))
+ open_editor(ui)
+ expect(page.get_by_role("alert").filter(has_text="unsupported source kind")).to_be_visible()
+ expect(page.get_by_role("button", name="Save workflow", exact=True)).to_have_count(0)
+ assert publication_task(record) == original
+ assert not ui.workflow_writes
+
+
+@pytest.mark.parametrize("field", ["profile", "generated_artifact_source"])
+def test_unknown_publication_fields_preserve_read_only_without_leaking_values(saved_output_ui, field):
+ ui, page = saved_output_ui, saved_output_ui.page
+ record = use_saved_output(saved_workflow(ui))
+ publication_task(record)["publication"][field] = {"private": "private-lineage-marker"}
+ original = copy.deepcopy(publication_task(record))
+ open_editor(ui)
+ expect(page.get_by_role("alert").filter(has_text="unsupported fields")).to_be_visible()
+ expect(page.get_by_role("button", name="Save workflow", exact=True)).to_have_count(0)
+ expect(page.get_by_text("private-lineage-marker", exact=False)).to_have_count(0)
+ assert publication_task(record) == original
+ assert not ui.workflow_writes
+
+
+@pytest.mark.parametrize("version,durable", [(2, True), (3, False)])
+def test_saved_output_requires_v3_durable_without_down_conversion(saved_output_ui, version, durable):
+ ui, page = saved_output_ui, saved_output_ui.page
+ record = use_saved_output(saved_workflow(ui))
+ publication_task(record)["publication"].pop("completion_policy")
+ record.update(definition_version=version, durable_execution=durable)
+ original = copy.deepcopy(record)
+ open_editor(ui) if version == 3 else ui.open(f"/workspace/workflows?workflow_id={record['id']}")
+ expect(page.get_by_role("alert").filter(has_text="durable definition-v3")).to_be_visible()
+ expect(page.get_by_role("button", name="Save workflow", exact=True)).to_have_count(0)
+ assert record["tasks"] == original["tasks"]
+ assert record["definition_version"] == version and record["durable_execution"] == durable
+ assert not ui.workflow_writes
+
+
+@pytest.mark.parametrize("format_name", ["md", "csv", "docx", "pdf", "pptx", "xml", "future_format", None])
+def test_unsupported_saved_format_stays_read_only_without_json_fallback(saved_output_ui, format_name):
+ ui, page = saved_output_ui, saved_output_ui.page
+ record = use_saved_output(saved_workflow(ui))
+ publication_task(record)["publication"]["artifact_format"] = format_name
+ original = copy.deepcopy(publication_task(record))
+ open_editor(ui)
+ expect(page.get_by_role("alert").filter(has_text="unsupported source/format combination")).to_be_visible()
+ expect(page.get_by_role("button", name="Save workflow", exact=True)).to_have_count(0)
+ assert publication_task(record) == original
+ assert not ui.workflow_writes
+
+
+@pytest.mark.parametrize("kind", ["text", "json", "document_results", "any"])
+def test_wrong_producer_kind_is_unavailable_and_cannot_save(saved_output_ui, kind):
+ ui, page = saved_output_ui, saved_output_ui.page
+ record = use_saved_output(saved_workflow(ui), "saved-records", "authoritative")
+ task = next(task for task in record["tasks"] if task["id"] == "records-task")
+ task["output_contract"] = {"kind": kind, "allow_partial": False, "require_complete_coverage": False}
+ # Keep the unrelated join valid while testing the explicit publishing input.
+ record["flow"]["nodes"][-2]["join"]["exports"][0]["else"] = {"node_id": "collect-findings", "output": "records"}
+ block = open_editor(ui)
+ expect(input_field(block, "producer").locator('option[value="saved-records"]')).to_have_attribute("disabled", "")
+ page.get_by_role("button", name="Save workflow", exact=True).click()
+ expect(page.get_by_role("alert").filter(has_text="exactly one required saved records")).to_be_visible()
+ assert not ui.workflow_writes
+ input_field(block, "producer").select_option("collect-findings")
+ input_field(block, "output").select_option("records")
+ assert publication_task(save_editor(ui).body)["inputs"][0]["source"]["node_id"] == "collect-findings"
+
+
+@pytest.mark.parametrize("invalid", ["missing", "multiple", "optional", "any", "loop_item", "diagnostics", "undeclared"])
+def test_saved_publication_binding_must_be_one_required_explicit_records_output(saved_output_ui, invalid):
+ ui, page = saved_output_ui, saved_output_ui.page
+ task = publication_task(use_saved_output(saved_workflow(ui)))
+ binding = task["inputs"][0]
+ if invalid == "missing":
+ task["inputs"] = []
+ elif invalid == "multiple":
+ task["inputs"].append({**copy.deepcopy(binding), "name": "second"})
+ elif invalid == "optional":
+ binding["required"] = False
+ elif invalid == "any":
+ binding["expected_kind"] = "any"
+ elif invalid == "loop_item":
+ binding.update(source={"kind": "loop_item", "loop_id": "each-source", "scope": "current"}, expected_kind="json")
+ else:
+ binding["source"]["output"] = invalid
+ original = copy.deepcopy(task)
+ block = open_editor(ui)
+ page.get_by_role("button", name="Save workflow", exact=True).click()
+ expect(page.get_by_role("alert").filter(has_text="exactly one required saved records")).to_be_visible()
+ if invalid == "loop_item":
+ expect(input_field(block, "source").locator('option[value="loop_item"]')).to_have_attribute("disabled", "")
+ assert task == original and not ui.workflow_writes
+
+
+def test_mixed_join_cannot_disguise_a_scalar_as_records(saved_output_ui):
+ ui, page = saved_output_ui, saved_output_ui.page
+ record = use_saved_output(saved_workflow(ui), "output-join", "selected_records")
+ record["flow"]["nodes"][-2]["join"]["exports"][0]["else"] = {"node_id": "analyze", "output": "json"}
+ block = open_editor(ui)
+ expect(input_field(block, "producer").locator('option[value="output-join"]')).to_have_attribute("disabled", "")
+ page.get_by_role("button", name="Save workflow", exact=True).click()
+ expect(page.get_by_role("alert").filter(has_text="exactly one required saved records")).to_be_visible()
+ assert not ui.workflow_writes
+
+
+@pytest.mark.parametrize("availability", ["skipped", "later", "missing"])
+def test_saved_output_does_not_weaken_reachability_or_required_producer_checks(saved_output_ui, availability):
+ ui, page = saved_output_ui, saved_output_ui.page
+ record = use_saved_output(saved_workflow(ui), "saved-records", "records")
+ nodes = record["flow"]["nodes"]
+ nodes[-2]["join"]["exports"][0]["else"] = {"node_id": "collect-findings", "output": "records"}
+ if availability == "skipped":
+ nodes[1]["run_when"] = {"op": "eq", "left": {"literal": False}, "right": {"literal": True}}
+ error = "requires an output that can be skipped"
+ elif availability == "later":
+ nodes.append(nodes.pop(1))
+ error = "earlier, reachable producer"
+ else:
+ publication_task(record)["inputs"][0]["source"]["node_id"] = "missing-producer"
+ error = "missing producer or undeclared output"
+ open_editor(ui)
+ page.get_by_role("button", name="Save workflow", exact=True).click()
+ expect(page.get_by_role("alert").filter(has_text=error)).to_be_visible()
+ assert not ui.workflow_writes
+
+
+@pytest.mark.parametrize("scope", ["user", "group"])
+def test_existing_explicit_partial_acceptance_round_trips_without_new_policy(saved_output_ui, scope):
+ ui = saved_output_ui
+ record = use_saved_output(saved_workflow(ui, scope))
+ body_task = next(task for task in record["tasks"] if task["id"] == "analyze-task")
+ body_task["output_contract"].update(allow_partial=True, require_complete_coverage=False)
+ loop = record["flow"]["nodes"][2]
+ collect = record["flow"]["nodes"][3]
+ loop["body"]["outputs"][0]["allow_partial"] = True
+ collect["output_contract"].update(allow_partial=True, require_complete_coverage=False)
+ original_flow = copy.deepcopy(record["flow"])
+ block = open_editor(ui, scope)
+ input_field(block, "allow partial").check()
+ policy_field(block).select_option("")
+ task = publication_task(save_editor(ui).body)
+ assert task["inputs"][0]["allow_partial"] is True
+ assert "completion_policy" not in task["publication"]
+ assert saved_workflow(ui, scope)["flow"] == original_flow
+ block = reopen_editor(ui, scope)
+ expect(input_field(block, "allow partial")).to_be_checked()
+ expect(policy_field(block)).to_have_value("")
+
+
+@pytest.mark.parametrize("scope,width,theme", [("user", 1280, "light"), ("group", 390, "dark")])
+def test_list_keyboard_source_join_and_completion_controls_fit_viewport(saved_output_ui, scope, width, theme):
+ ui, page = saved_output_ui, saved_output_ui.page
+ original = copy.deepcopy(saved_workflow(ui, scope))
+ block = open_editor(ui, scope, width=width, height=844, theme=theme)
+ source = source_field(block)
+ source.focus()
+ source.press("End")
+ expect(source).to_have_value("saved_output")
+ producer = input_field(block, "producer")
+ producer.focus()
+ producer.press("End")
+ expect(producer).to_have_value("output-join")
+ expect(input_field(block, "output")).to_have_value("selected_records")
+ policy = policy_field(block)
+ policy.focus()
+ policy.press("ArrowUp")
+ expect(policy).to_have_value("approved")
+ ui.assert_no_overflow()
+ dialog = page.get_by_role("dialog", name="Edit workflow", exact=True)
+ assert dialog.evaluate("element => element.scrollWidth <= element.clientWidth + 1")
+ for control in (source, producer, format_field(block), policy):
+ control.scroll_into_view_if_needed()
+ box = control.bounding_box()
+ assert box and box["x"] >= 0 and box["x"] + box["width"] <= width + 1
+ save = page.get_by_role("button", name="Save workflow", exact=True)
+ save.focus()
+ save.press("Enter")
+ expect(dialog).to_have_count(0)
+ payload = ui.workflow_writes[-1].body
+ assert payload["flow"] == original["flow"]
+ assert [task["id"] for task in payload["tasks"]] == [task["id"] for task in original["tasks"]]
+ assert publication_task(payload)["publication"]["source_kind"] == "saved_output"
+ assert_no_execution(ui)
+
+
+@pytest.mark.parametrize("capabilities", [
+ None,
+ {},
+ [{"source_kind": "saved_output", "output_kinds": "records", "artifact_formats": ["json"]}],
+ [{"source_kind": "saved_output", "output_kinds": ["records"], "artifact_formats": None}],
+ [SOURCE_CAPABILITIES[1], SOURCE_CAPABILITIES[1]],
+])
+def test_malformed_publication_capabilities_fail_closed(saved_output_ui, capabilities):
+ ui, page = saved_output_ui, saved_output_ui.page
+ ui.option_overrides["publication_source_capabilities"] = capabilities
+ ui.open("/workspace/workflows")
+ page.get_by_role("button", name="Edit Publication workflow", exact=True).click()
+ expect(page.get_by_role("alert").filter(has_text="invalid publication source capabilities")).to_be_visible()
+ expect(page.get_by_role("button", name="Save workflow", exact=True)).to_have_count(0)
+ assert not ui.workflow_writes
+
+
+@pytest.mark.parametrize("scope,state", [("user", "indexed_ready"), ("group", "unavailable")])
+def test_saved_export_keeps_exact_attempt_completion_and_unavailable_readback(saved_output_ui, scope, state):
+ ui, page = saved_output_ui, saved_output_ui.page
+ destination = "group" if scope == "group" else "personal"
+ status = publication_status(
+ destination, state=state, policy_satisfied=state == "indexed_ready",
+ approval="approved" if scope == "group" else "not_required",
+ processing="complete" if state == "indexed_ready" else "unavailable",
+ screening="available", index="ready" if state == "indexed_ready" else "unavailable",
+ reason_code=f"publication_{state}", retryable=False,
+ unresolved_stages=[] if state == "indexed_ready" else ["processing"],
+ )
+ ui.set_publication_status(status, scope=scope, attempt=2)
+ use_saved_output(saved_workflow(ui, scope))
+ key = publication_key(scope)
+ eid = execution_id(key[1], key[2], "publish")
+ for item in (ui.execution_pages[key][""]["items"][0], ui.attempt_pages[(*key, eid)][""]["items"][0]):
+ item["workflow_validation"] = {
+ "version": 1, "status": "accepted_partial", "counts": {"records": 2},
+ "reason_codes": ["partial_output_accepted"],
+ }
+ ui.open("/groups" if scope == "group" else "/workspace/workflows", width=390, height=844)
+ if scope == "group":
+ page.get_by_label("Group workspace", exact=True).select_option(GROUP_ID)
+ row = page.get_by_role("listitem").filter(has_text=saved_workflow(ui, scope)["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()
+ page.get_by_role("button", name=f"Show execution attempts for {eid}", exact=True).click()
+ details = page.get_by_role("region", name=f"Publication for execution {eid} attempt 2", exact=True)
+ expect(details).to_contain_text(status["id"])
+ expect(page.get_by_text("accepted_partial", exact=False).first).to_be_visible()
+ if state == "indexed_ready":
+ expect(details).to_contain_text("Saved completion observation: Publication indexed and ready")
+ 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()
+ else:
+ expect(details).to_contain_text("unavailable")
+ expect(page.get_by_role("button", name="Retry task", exact=True)).to_have_count(0)
+ expect(page.get_by_text("generated_artifact_source", exact=False)).to_have_count(0)
+ assert not any(".blob." in (request.path or "") for request in ui.requests)
+ ui.assert_no_overflow()
+ assert_no_execution(ui)
+
+
+@pytest.mark.parametrize("row_count,validation", [(2, "valid"), (0, "valid"), (2, "accepted_partial")])
+def test_saved_output_uses_existing_generated_file_card_and_authorized_download(saved_output_ui, row_count, validation):
+ ui, page = saved_output_ui, saved_output_ui.page
+ summary = f"{row_count} exact saved records ({validation})"
+ artifact = ui.messages[ARTIFACT_CONVERSATION_ID][0]["metadata"]["generated_tabular_outputs"][0]
+ artifact.update(row_count=row_count, summary=summary)
+ ui.artifact_bytes = b"[]" if row_count == 0 else ARTIFACT_BYTES
+ ui.open(f"/chat?conversation_id={ARTIFACT_CONVERSATION_ID}", width=390, height=844)
+ expect(page.get_by_text("Generated JSON export", exact=True)).to_be_visible()
+ expect(page.get_by_text(f"{row_count} rows", exact=True)).to_be_visible()
+ expect(page.get_by_text(summary, exact=True)).to_be_visible()
+ expect(page.get_by_text(re.compile(r"^Analyze .* artifact$"))).to_have_count(0)
+ expect(page.get_by_text("generated_artifact_source", exact=False)).to_have_count(0)
+ button = page.get_by_role("button", name="Download JSON", exact=True)
+ button.scroll_into_view_if_needed()
+ button.focus()
+ with page.expect_download() as pending:
+ button.press("Enter")
+ download = pending.value
+ assert download.suggested_filename == ARTIFACT_FILE_NAME
+ artifact_path = ROOT / "ui_tests" / "artifacts" / "saved-output-publication-download.json"
+ artifact_path.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ download.save_as(artifact_path)
+ assert artifact_path.read_bytes() == ui.artifact_bytes
+ finally:
+ artifact_path.unlink(missing_ok=True)
+ assert len(ui.artifact_downloads) == 1
+ ui.assert_no_overflow()
+ assert_no_execution(ui)
+
+
+@pytest.mark.parametrize("capability,row_source", [
+ ("file_export", "function_result"), ("file_export", None), ("tabular", "saved_records"),
+])
+def test_other_compact_exports_keep_existing_summary_behavior(saved_output_ui, capability, row_source):
+ ui, page = saved_output_ui, saved_output_ui.page
+ artifact = ui.messages[ARTIFACT_CONVERSATION_ID][0]["metadata"]["generated_tabular_outputs"][0]
+ artifact.update(capability=capability, row_source=row_source)
+ ui.open(f"/chat?conversation_id={ARTIFACT_CONVERSATION_ID}")
+ expect(page.get_by_text("Generated JSON export", exact=True)).to_be_visible()
+ expect(page.get_by_text(artifact["summary"], exact=True)).to_have_count(0)
+ expect(page.get_by_role("button", name="Download JSON", exact=True)).to_be_visible()
+
+
+def test_saved_record_card_summary_is_inert_text(saved_output_ui):
+ ui, page = saved_output_ui, saved_output_ui.page
+ artifact = ui.messages[ARTIFACT_CONVERSATION_ID][0]["metadata"]["generated_tabular_outputs"][0]
+ artifact["summary"] = ' 2 exact saved records'
+ ui.open(f"/chat?conversation_id={ARTIFACT_CONVERSATION_ID}")
+ expect(page.get_by_text(artifact["summary"], exact=True)).to_be_visible()
+ expect(page.locator('img[src="/private-record-source"]')).to_have_count(0)
+ assert page.evaluate("window.savedOutputInjected === undefined")
+ assert not any(request.path == "/private-record-source" for request in ui.requests)