From 3f5f0087e708303acb2499e4c6a051315fcb46a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Fri, 3 Jul 2026 10:53:37 +0800 Subject: [PATCH 1/2] feat: add infraguard review pipeline --- src/iac_code/a2a/artifacts.py | 11 + src/iac_code/a2a/pipeline_events.py | 18 +- src/iac_code/a2a/pipeline_executor.py | 82 +- src/iac_code/a2a/pipeline_snapshot.py | 64 +- src/iac_code/a2a/pipeline_stream.py | 21 +- src/iac_code/agent/agent_loop.py | 25 +- src/iac_code/agent/message.py | 1 + src/iac_code/cli/output_formats.py | 26 +- .../i18n/locales/de/LC_MESSAGES/messages.po | 581 +++++- .../i18n/locales/es/LC_MESSAGES/messages.po | 575 +++++- .../i18n/locales/fr/LC_MESSAGES/messages.po | 578 +++++- .../i18n/locales/ja/LC_MESSAGES/messages.po | 538 ++++- .../i18n/locales/pt/LC_MESSAGES/messages.po | 568 +++++- .../i18n/locales/zh/LC_MESSAGES/messages.po | 523 ++++- src/iac_code/pipeline/__init__.py | 21 + src/iac_code/pipeline/display_names.py | 1 + .../pipeline/engine/complete_step_tool.py | 608 +++++- .../pipeline/engine/completion_guard_state.py | 80 +- src/iac_code/pipeline/engine/loader.py | 83 +- .../pipeline/engine/pipeline_runner.py | 242 ++- src/iac_code/pipeline/engine/prerequisites.py | 1801 +++++++++++++++++ src/iac_code/pipeline/engine/recovery.py | 19 +- src/iac_code/pipeline/engine/session.py | 46 +- src/iac_code/pipeline/engine/step_executor.py | 51 +- src/iac_code/pipeline/engine/step_spec.py | 62 +- .../pipeline/engine/sub_pipeline_executor.py | 123 +- src/iac_code/pipeline/selling/pipeline.yaml | 267 ++- .../pipeline/selling/prompts/reviewing.md | 68 +- .../selling/skills/iac-aliyun-review/SKILL.md | 170 +- .../skills/iac-aliyun-review/evals.json | 284 +++ .../pipeline/selling/tools/__init__.py | 3 + .../selling/tools/infraguard_scan_tool.py | 810 ++++++++ .../services/telemetry/content_serializer.py | 20 +- src/iac_code/tools/base.py | 13 + src/iac_code/tools/result_storage.py | 1 + src/iac_code/tools/tool_executor.py | 33 +- src/iac_code/types/stream_events.py | 1 + src/iac_code/ui/components/select.py | 10 +- src/iac_code/ui/core/in_place_render.py | 4 +- src/iac_code/ui/renderer.py | 10 +- src/iac_code/ui/repl.py | 521 ++++- src/iac_code/ui/stream_accumulator.py | 2 + src/iac_code/ui/transcript_view.py | 22 +- src/iac_code/utils/tool_result_redaction.py | 93 + tests/a2a/test_artifacts.py | 43 + tests/a2a/test_pipeline_events.py | 137 ++ tests/a2a/test_pipeline_executor.py | 241 +++ tests/a2a/test_pipeline_snapshot.py | 143 ++ tests/a2a/test_pipeline_stream.py | 94 + tests/agent/test_agent_loop_new.py | 95 + tests/agent/test_message.py | 15 + tests/cli/test_output_formats.py | 94 + .../engine/test_complete_step_tool.py | 739 +++++++ tests/pipeline/engine/test_loader.py | 82 + .../engine/test_loader_allow_user_escapes.py | 144 +- .../engine/test_pipeline_runner_interrupt.py | 163 ++ .../test_pipeline_runner_sidecar_path.py | 209 +- tests/pipeline/engine/test_prerequisites.py | 1622 +++++++++++++++ tests/pipeline/engine/test_recovery.py | 165 ++ tests/pipeline/engine/test_session.py | 16 + tests/pipeline/engine/test_step_executor.py | 239 +++ .../engine/test_step_executor_integration.py | 19 + tests/pipeline/engine/test_step_spec.py | 20 + .../engine/test_sub_pipeline_executor.py | 271 +++ .../skills/test_iac_aliyun_review_skill.py | 162 ++ .../selling/test_infraguard_scan_tool.py | 915 +++++++++ tests/pipeline/selling/test_memory_policy.py | 12 +- .../selling/test_pipeline_reviewing.py | 477 +++++ tests/pipeline/test_discovery.py | 64 + tests/test_i18n.py | 76 + .../test_telemetry/test_content_serializer.py | 119 +- tests/tools/test_tool_executor.py | 44 + tests/ui/components/test_select.py | 33 + tests/ui/core/test_in_place_render.py | 10 + tests/ui/test_renderer_events.py | 32 + tests/ui/test_renderer_helpers.py | 34 + tests/ui/test_repl_swap_session_pipeline.py | 943 ++++++++- tests/ui/test_stream_accumulator.py | 9 + tests/ui/test_transcript_view.py | 94 +- 79 files changed, 16118 insertions(+), 537 deletions(-) create mode 100644 src/iac_code/pipeline/engine/prerequisites.py create mode 100644 src/iac_code/pipeline/selling/skills/iac-aliyun-review/evals.json create mode 100644 src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py create mode 100644 src/iac_code/utils/tool_result_redaction.py create mode 100644 tests/pipeline/engine/test_prerequisites.py create mode 100644 tests/pipeline/selling/skills/test_iac_aliyun_review_skill.py create mode 100644 tests/pipeline/selling/test_infraguard_scan_tool.py create mode 100644 tests/pipeline/selling/test_pipeline_reviewing.py diff --git a/src/iac_code/a2a/artifacts.py b/src/iac_code/a2a/artifacts.py index 3be0ebbe..db4b93bc 100644 --- a/src/iac_code/a2a/artifacts.py +++ b/src/iac_code/a2a/artifacts.py @@ -23,6 +23,11 @@ from iac_code.utils.public_errors import sanitize_public_text from iac_code.utils.public_paths import relativize_public_file_uri from iac_code.utils.state_io import atomic_write_bytes +from iac_code.utils.tool_result_redaction import ( + REDACTED_TOOL_RESULT_VALUE, + is_file_content_key, + redact_file_content_from_json_string, +) PUBLIC_ARTIFACT_URI_PREFIX = "iac-code-artifact://" _PUBLIC_ARTIFACT_URI_SCHEME = "iac-code-artifact" @@ -137,6 +142,9 @@ def sanitize_public_artifact_data( if not key_is_public_path and _is_sensitive_output_key(key_name): sanitized[output_key] = "[REDACTED]" continue + if not key_is_public_path and is_file_content_key(key_name): + sanitized[output_key] = REDACTED_TOOL_RESULT_VALUE + continue if _is_artifact_uri_key(key_name): if isinstance(raw_value, str): sanitized_value = _sanitize_artifact_string(key_name, raw_value, public_path_roots=public_path_roots) @@ -159,6 +167,7 @@ def sanitize_public_tool_output_data( public_path_roots: Iterable[Mapping[str, str]] | None = None, ) -> Any: if isinstance(value, str): + value = redact_file_content_from_json_string(value) return sanitize_public_artifact_text(value, fallback_summary="", public_path_roots=public_path_roots) if isinstance(value, list): return [sanitize_public_tool_output_data(item, public_path_roots=public_path_roots) for item in value] @@ -177,6 +186,8 @@ def sanitize_public_tool_output_data( sanitized[output_key] = sanitize_public_artifact_data(raw_value, public_path_roots=public_path_roots) elif not key_is_public_path and _is_sensitive_output_key(key_name): sanitized[output_key] = "[REDACTED]" + elif not key_is_public_path and is_file_content_key(key_name): + sanitized[output_key] = REDACTED_TOOL_RESULT_VALUE else: sanitized[output_key] = sanitize_public_tool_output_data(raw_value, public_path_roots=public_path_roots) return sanitized diff --git a/src/iac_code/a2a/pipeline_events.py b/src/iac_code/a2a/pipeline_events.py index 92566ba4..c1295c4b 100644 --- a/src/iac_code/a2a/pipeline_events.py +++ b/src/iac_code/a2a/pipeline_events.py @@ -1366,13 +1366,29 @@ def _artifact_from_spec(spec: Any, root: dict[str, Any]) -> dict[str, Any] | Non media_type = _artifact_spec_field(spec, "media_type") or _artifact_spec_field(spec, "mediaType") or "auto" if media_type == "auto": media_type = _media_type_for_filename(path) + role = _artifact_spec_field(spec, "role") or "final" + supersedes_path = path + supersedes_expression = _artifact_spec_field(spec, "supersedes_path") or _artifact_spec_field( + spec, "supersedesPath" + ) + if supersedes_expression is not None: + resolved_supersedes_path = _resolve_artifact_expression(root, supersedes_expression) + if isinstance(resolved_supersedes_path, str): + supersedes_path = resolved_supersedes_path try: filename = artifact_filename_from_path(path) except UnsafeArtifactNameError: filename = "artifact.txt" - return {"filename": filename, "mediaType": media_type, "content": content} + return { + "filename": filename, + "mediaType": media_type, + "content": content, + "role": role, + "supersedesPath": sanitize_public_artifact_text(supersedes_path), + "supersedesKey": fingerprint_text(supersedes_path), + } def _artifact_spec_field(spec: Any, field_name: str) -> str | None: diff --git a/src/iac_code/a2a/pipeline_executor.py b/src/iac_code/a2a/pipeline_executor.py index 5b67c074..58a98937 100644 --- a/src/iac_code/a2a/pipeline_executor.py +++ b/src/iac_code/a2a/pipeline_executor.py @@ -12,6 +12,7 @@ from typing import Any import httpx +import yaml from a2a.types import Message, Role, TaskState, TaskStatus, TaskStatusUpdateEvent from a2a.utils.errors import InvalidParamsError @@ -51,7 +52,8 @@ from iac_code.pipeline.engine.cleanup import CleanupLedger from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType from iac_code.pipeline.engine.handoff import build_handoff_summary, terminal_outcome_from_completed_event -from iac_code.pipeline.engine.loader import load_pipeline_dir +from iac_code.pipeline.engine.loader import _resolve_feature_flags, load_pipeline_dir +from iac_code.pipeline.engine.prerequisites import inspect_prerequisites from iac_code.pipeline.engine.public_errors import public_error from iac_code.pipeline.engine.session import PipelineSession from iac_code.pipeline.engine.user_input import PipelineUserInput, normalize_pipeline_user_input @@ -398,7 +400,16 @@ def fresh_pipeline_factory() -> Any: ) if selected.pipeline is not pipeline: pipeline = selected.pipeline + publisher = self._publisher( + event_queue=event_queue, + pipeline=pipeline, + task_id=task_id, + context_id=context_id, + session_id=ctx.session_id, + cwd=cwd, + ) pipeline_runtime.pipeline = pipeline + pipeline_runtime.publisher = publisher self._task_store.mirror_context(ctx) stream = selected.stream ctx.active_task_id = task.task_id @@ -947,8 +958,19 @@ def _create_pipeline( session_storage: SessionStorage, resume_from_sidecar: bool = True, ) -> Any: + pipeline_name = get_pipeline_name() + prerequisite_resolution = self._inspect_pipeline_prerequisite_metadata( + pipeline_name=pipeline_name, + cwd=cwd, + session_id=session_id, + session_storage=session_storage, + resume_from_sidecar=resume_from_sidecar, + ) + create_kwargs: dict[str, Any] = {} + if prerequisite_resolution is not None: + create_kwargs["prerequisite_resolution"] = prerequisite_resolution return create_pipeline( - get_pipeline_name(), + pipeline_name, provider_manager=runtime.provider_manager, base_tool_registry=runtime.tool_registry, session_storage=session_storage, @@ -957,8 +979,64 @@ def _create_pipeline( resume_from_sidecar=resume_from_sidecar, surface="a2a", backup_service=self._backup_service, + **create_kwargs, ) + def _inspect_pipeline_prerequisite_metadata( + self, + *, + pipeline_name: str, + cwd: str, + session_id: str, + session_storage: SessionStorage, + resume_from_sidecar: bool, + ) -> dict[str, Any] | None: + if resume_from_sidecar: + sidecar_metadata = self._sidecar_prerequisite_metadata( + cwd=cwd, + session_id=session_id, + session_storage=session_storage, + ) + if sidecar_metadata is not None: + return sidecar_metadata + + raw = self._load_pipeline_raw_config(pipeline_name) + raw_prerequisites = raw.get("prerequisites") or {} + if not isinstance(raw_prerequisites, dict): + raw_prerequisites = {} + raw_feature_flags = raw.get("feature_flags") + feature_flags = _resolve_feature_flags(raw_feature_flags if isinstance(raw_feature_flags, dict) else None) + resolution = inspect_prerequisites(raw_prerequisites, feature_flags=feature_flags) + return resolution.to_metadata() + + def _sidecar_prerequisite_metadata( + self, + *, + cwd: str, + session_id: str, + session_storage: SessionStorage, + ) -> dict[str, Any] | None: + try: + raw_session_dir = session_storage.session_dir(cwd, session_id) + meta_path = Path(raw_session_dir) / "pipeline" / "meta.yaml" + if not meta_path.exists(): + return None + raw = yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {} + except Exception: + logger.debug("Failed to peek A2A pipeline sidecar prerequisites", exc_info=True) + return None + if not isinstance(raw, dict): + return None + metadata = raw.get("prerequisites") + return dict(metadata) if isinstance(metadata, dict) else None + + def _load_pipeline_raw_config(self, pipeline_name: str) -> dict[str, Any]: + pipeline_dir = discover_pipelines().get(pipeline_name) + if pipeline_dir is None: + return {} + raw = yaml.safe_load((pipeline_dir / "pipeline.yaml").read_text(encoding="utf-8")) or {} + return raw if isinstance(raw, dict) else {} + def _set_pipeline_telemetry_correlation(self, pipeline: Any, *, task_id: str, context_id: str) -> None: set_correlation = getattr(pipeline, "set_telemetry_correlation", None) if not callable(set_correlation): diff --git a/src/iac_code/a2a/pipeline_snapshot.py b/src/iac_code/a2a/pipeline_snapshot.py index e7644461..f999792d 100644 --- a/src/iac_code/a2a/pipeline_snapshot.py +++ b/src/iac_code/a2a/pipeline_snapshot.py @@ -218,7 +218,7 @@ def _hydrate_existing_snapshot(self, existing_snapshot: dict[str, Any] | None) - self._hydrate_messages() self._hydrate_display_indexes("candidateDetails", self._candidate_detail_indexes, ("detailId", "id")) self._hydrate_display_indexes("diagrams", self._diagram_indexes, ("diagramId", "id")) - self._hydrate_display_indexes("artifacts", self._artifact_indexes, ("artifactId", "artifact_id", "id")) + self._hydrate_artifact_indexes() self._hydrate_display_indexes("permissions", self._permission_indexes, ("permissionId", "toolUseId", "id")) self._hydrate_display_indexes("toolResults", self._tool_result_indexes, ("toolUseId", "resultId", "id")) self._hydrate_control_history("inputHistory", self._input_history_keys) @@ -294,6 +294,26 @@ def _hydrate_messages(self) -> None: valid_messages.append(message) self._snapshot["display"]["messages"] = valid_messages + def _hydrate_artifact_indexes(self) -> None: + unique_items: list[dict[str, Any]] = [] + seen_item_keys: set[str] = set() + for item in self._snapshot["display"]["artifacts"]: + if not isinstance(item, dict): + continue + event_id = _string_or_none(item.get("eventId")) + if event_id is not None: + self._seen_event_ids.add(event_id) + item_id = _first_string_value(item, ("artifactId", "artifact_id", "id", "eventId")) + if item_id is None: + continue + item_key = _artifact_replacement_key(item_id, item, None) + if item_key in seen_item_keys: + continue + seen_item_keys.add(item_key) + self._artifact_indexes[item_key] = len(unique_items) + unique_items.append(item) + self._snapshot["display"]["artifacts"] = unique_items + def _hydrate_display_indexes( self, display_key: str, @@ -788,9 +808,10 @@ def _upsert_artifact_item(self, event: dict[str, Any]) -> None: _merge_event_coordinates(item, event) items = self._snapshot["display"]["artifacts"] - index = self._artifact_indexes.get(item_id) + item_key = _artifact_replacement_key(item_id, item, event) + index = self._artifact_indexes.get(item_key) if index is None: - self._artifact_indexes[item_id] = len(items) + self._artifact_indexes[item_key] = len(items) items.append(item) else: existing = copy.deepcopy(items[index]) @@ -1460,6 +1481,43 @@ def _artifact_item_id(artifact: dict[str, Any] | None, data: dict[str, Any], eve return f"artifact-{_sequence_value(event)}" +def _artifact_replacement_key( + item_id: str, + item: dict[str, Any], + event: dict[str, Any] | None, +) -> str: + supersedes_key = ( + _string_or_none(item.get("supersedesKey")) + or _string_or_none(item.get("supersedes_key")) + or _string_or_none(item.get("supersedesFingerprint")) + or _string_or_none(item.get("supersedes_fingerprint")) + ) + if supersedes_key is not None: + return f"supersedes-key:{_artifact_replacement_scope(item, event)}:{supersedes_key}" + supersedes_path = _public_supersedes_path_for_replacement(item) + if supersedes_path is None: + return f"id:{item_id}" + return f"supersedes:{_artifact_replacement_scope(item, event)}:{supersedes_path}" + + +def _public_supersedes_path_for_replacement(item: dict[str, Any]) -> str | None: + supersedes_path = _string_or_none(item.get("supersedesPath")) or _string_or_none(item.get("supersedes_path")) + if supersedes_path in {None, "[PATH]"}: + return None + return supersedes_path + + +def _artifact_replacement_scope(item: dict[str, Any], event: dict[str, Any] | None) -> str: + for source in (event, item): + if not isinstance(source, dict): + continue + candidate = _dict_or_none(source.get("candidate")) + run_id = _run_id(candidate) + if run_id is not None: + return f"candidate:{run_id}" + return "pipeline" + + def _drop_legacy_artifact_uri(data: dict[str, Any] | None) -> None: if data is None: return diff --git a/src/iac_code/a2a/pipeline_stream.py b/src/iac_code/a2a/pipeline_stream.py index c9bac734..495bd09f 100644 --- a/src/iac_code/a2a/pipeline_stream.py +++ b/src/iac_code/a2a/pipeline_stream.py @@ -39,6 +39,7 @@ PENDING_BACKUP_VISIBILITY = "pending_backup" COMMITTED_BACKUP_VISIBILITY = "committed" BACKUP_COMMITTED_EVENT_TYPE = "backup_committed" +_ARTIFACT_SEMANTIC_METADATA_KEYS = ("role", "supersedesPath", "supersedesKey", "supersedesFingerprint") _RECOVERY_SEMANTIC_EVENT_TYPES = { "pipeline_started", "pipeline_resumed", @@ -748,11 +749,14 @@ async def _maybe_externalize_artifact( try: if event_type == "artifact_created": - result = {"artifact": envelope.get("artifact")} + original_artifact = envelope.get("artifact") + result = {"artifact": original_artifact} elif tool_result is not None: + original_artifact = None result = tool_result.result else: return None + artifact_semantic_metadata = _artifact_semantic_metadata(original_artifact, envelope.get("data")) artifact_metadata = _extract_artifact_metadata(result, self.artifact_store) except Exception: logger.warning("Failed to externalize A2A pipeline tool artifact", exc_info=True) @@ -763,7 +767,7 @@ async def _maybe_externalize_artifact( envelope["eventType"] = "artifact_created" envelope["scope"] = envelope.get("scope") or "pipeline" envelope["status"] = "working" - envelope["artifact"] = artifact_metadata + envelope["artifact"] = {**artifact_metadata, **artifact_semantic_metadata} base_data: dict[str, Any] = {} envelope_data = envelope.get("data") if event_type == "artifact_created" and isinstance(envelope_data, dict): @@ -776,6 +780,7 @@ async def _maybe_externalize_artifact( "byteSize": artifact_metadata.get("byteSize"), "sha256": artifact_metadata.get("sha256"), "uri": artifact_metadata.get("uri"), + **artifact_semantic_metadata, } if tool_result is not None and A2AExposureType.TOOL_TRACE in self.exposure_types: envelope["data"].update( @@ -848,6 +853,18 @@ def _tool_result_from(event: Any) -> ToolResultEvent | None: return inner if isinstance(inner, ToolResultEvent) else None +def _artifact_semantic_metadata(*sources: Any) -> dict[str, Any]: + metadata: dict[str, Any] = {} + for source in sources: + if not isinstance(source, dict): + continue + for key in _ARTIFACT_SEMANTIC_METADATA_KEYS: + value = source.get(key) + if isinstance(value, str) and value: + metadata.setdefault(key, value) + return metadata + + def _unwrap_stream_event(event: Any) -> Any: while isinstance(event, SubPipelineStreamEvent): event = event.inner diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index 5bf8a728..82d1025b 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -28,7 +28,7 @@ ) from iac_code.services.session_usage import SessionUsageStore, SessionUsageTotals from iac_code.tools.base import ToolContext, ToolRegistry, ToolResult -from iac_code.tools.result_storage import ResultStorage +from iac_code.tools.result_storage import EXTERNALIZED_RESULT_PATH_METADATA_KEY, ResultStorage from iac_code.tools.tool_executor import ToolCallRequest, ToolExecutor from iac_code.types.permissions import PermissionAuditSettings, PermissionResult from iac_code.types.stream_events import ( @@ -215,6 +215,7 @@ def __init__( tool_context_trusted_read_directories: list[str] | None = None, tool_context_relative_read_directories: list[str] | None = None, pipeline_mode: bool = False, + tool_context_env_overrides: dict[str, str] | None = None, root_session_id: str | None = None, transcript_id: str | None = None, result_storage_dir: str | Path | None = None, @@ -238,6 +239,7 @@ def __init__( self._tool_context_trusted_read_directories = list(tool_context_trusted_read_directories or []) self._tool_context_relative_read_directories = list(tool_context_relative_read_directories or []) self._pipeline_mode = pipeline_mode + self._tool_context_env_overrides = dict(tool_context_env_overrides or {}) self._auto_trigger_skills = auto_trigger_skills or [] self._auto_loaded_skills: set[str] = set() self._current_git_branch: str | None = None @@ -922,6 +924,8 @@ async def _run_streaming_inner( system=system_prompt, tools=provider_tools, ): + if isinstance(event, ToolUseStartEvent): + event = replace(event, renderer_tool=self.tool_registry.get(event.name)) yield event # Forward all provider events to UI # Collect data from events @@ -1019,6 +1023,7 @@ async def _run_streaming_inner( trusted_read_directories=list(self._tool_context_trusted_read_directories), relative_read_directories=list(self._tool_context_relative_read_directories), pipeline_mode=self._pipeline_mode, + env_overrides=dict(self._tool_context_env_overrides), ) allowed_requests: list[ToolCallRequest] = [] @@ -1228,14 +1233,15 @@ async def poll_event_queues(): terminal_step_result = True processed = self._result_storage.process(req.id, result.content) self._mark_read_memory_tool_result(req, result) + result_metadata = self._tool_result_event_metadata(result.metadata, processed) yield ToolResultEvent( tool_use_id=req.id, tool_name=req.name, result=processed.content, is_error=result.is_error, - metadata=result.metadata, public_path_roots=public_path_roots, + metadata=result_metadata, ) tool_result_blocks.append( @@ -1243,6 +1249,7 @@ async def poll_event_queues(): tool_use_id=req.id, content=processed.content, is_error=result.is_error, + metadata=self._tool_result_block_metadata(processed), ) ) @@ -1297,6 +1304,20 @@ async def _submit_queued_inputs_after_tool_call( ) yield QueuedInputSubmittedEvent(text=text) + @staticmethod + def _tool_result_event_metadata(metadata: dict[str, Any] | None, processed: Any) -> dict[str, Any] | None: + if not getattr(processed, "is_externalized", False) or not getattr(processed, "file_path", None): + return metadata + event_metadata = dict(metadata or {}) + event_metadata[EXTERNALIZED_RESULT_PATH_METADATA_KEY] = str(processed.file_path) + return event_metadata + + @staticmethod + def _tool_result_block_metadata(processed: Any) -> dict[str, Any]: + if not getattr(processed, "is_externalized", False) or not getattr(processed, "file_path", None): + return {} + return {EXTERNALIZED_RESULT_PATH_METADATA_KEY: str(processed.file_path)} + def _mark_read_memory_tool_result(self, request: ToolCallRequest, result: ToolResult) -> None: if request.name != "read_memory" or result.is_error or self._memory_recall_service is None: return diff --git a/src/iac_code/agent/message.py b/src/iac_code/agent/message.py index 7a2b0eea..187e1ef6 100644 --- a/src/iac_code/agent/message.py +++ b/src/iac_code/agent/message.py @@ -31,6 +31,7 @@ class ToolResultBlock(BaseModel): tool_use_id: str content: str is_error: bool = False + metadata: dict[str, Any] = Field(default_factory=dict) class ThinkingBlock(BaseModel): diff --git a/src/iac_code/cli/output_formats.py b/src/iac_code/cli/output_formats.py index 4f050227..871adae7 100644 --- a/src/iac_code/cli/output_formats.py +++ b/src/iac_code/cli/output_formats.py @@ -13,6 +13,7 @@ from iac_code.a2a.artifacts import sanitize_public_tool_output_data from iac_code.services.permissions.audit import build_input_summary +from iac_code.tools.result_storage import EXTERNALIZED_RESULT_PATH_METADATA_KEY from iac_code.types.stream_events import ( ErrorEvent, MessageEndEvent, @@ -42,12 +43,29 @@ def _public_tool_result(event: ToolResultEvent) -> Any: def _public_tool_metadata(event: ToolResultEvent, metadata: Any) -> Any: + public_metadata = _strip_internal_tool_metadata(metadata) + if event.is_error: + public_metadata = _sanitize_public_value(public_metadata, public_path_roots=event.public_path_roots) return sanitize_public_tool_output_data( - _sanitize_public_value(metadata, public_path_roots=event.public_path_roots) if event.is_error else metadata, + public_metadata, public_path_roots=event.public_path_roots, ) +def _strip_internal_tool_metadata(metadata: Any) -> Any: + if isinstance(metadata, dict): + return { + str(key): _strip_internal_tool_metadata(value) + for key, value in metadata.items() + if key != EXTERNALIZED_RESULT_PATH_METADATA_KEY + } + if isinstance(metadata, list): + return [_strip_internal_tool_metadata(item) for item in metadata] + if isinstance(metadata, tuple): + return [_strip_internal_tool_metadata(item) for item in metadata] + return metadata + + def _sanitize_public_value(value: Any, *, public_path_roots: list[dict[str, str]] | None = None) -> Any: if isinstance(value, str): return sanitize_public_text(value, public_path_roots=public_path_roots) @@ -63,6 +81,12 @@ def _sanitize_public_value(value: Any, *, public_path_roots: list[dict[str, str] def _stream_json_event_data(event: StreamEvent) -> dict[str, Any]: + if isinstance(event, ToolUseStartEvent): + return { + "tool_use_id": event.tool_use_id, + "name": event.name, + "type": event.type, + } if isinstance(event, PermissionRequestEvent): return { "input_summary": build_input_summary(event.tool_name, event.tool_input), diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 3bdb9077..6b5caadd 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -608,8 +608,9 @@ msgstr "" " Dokumentation: https://aliyun.github.io/iac-" "code/de/docs/configuration/authentication\n" -#: src/iac_code/cli/headless.py src/iac_code/ui/renderer.py -#: src/iac_code/ui/repl.py +#: src/iac_code/cli/headless.py +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/renderer.py src/iac_code/ui/repl.py #, python-brace-format msgid "Error: {error}" msgstr "Fehler: {error}" @@ -2953,6 +2954,10 @@ msgstr "ROS-Kostenschätzung" msgid "ROS Deploy" msgstr "ROS-Bereitstellung" +#: src/iac_code/pipeline/display_names.py +msgid "InfraGuard scan" +msgstr "InfraGuard-Scan" + #: src/iac_code/pipeline/engine/ask_user_question_tool.py msgid "" "Pipeline-only tool that asks the user to choose an option or type " @@ -3115,6 +3120,86 @@ msgstr "" "{count} Rollback-Bereinigungsressourcen erkannt; Bereinigung wird " "gestartet." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing ran write_file/edit_file after ros_validate_template; rerun " +"ros_validate_template and infraguard_scan for the same file_path." +msgstr "" +"reviewing hat nach ros_validate_template write_file/edit_file ausgeführt;" +" führen Sie ros_validate_template und infraguard_scan für denselben " +"file_path erneut aus." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing must validate the repaired template with ros_validate_template " +"for the same file_path." +msgstr "" +"reviewing muss die reparierte Vorlage mit ros_validate_template für " +"denselben file_path validieren." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing ran write_file/edit_file after the final InfraGuard scan; rerun" +" ros_validate_template and infraguard_scan for the same file_path." +msgstr "" +"reviewing hat nach dem finalen InfraGuard-Scan write_file/edit_file " +"ausgeführt; führen Sie ros_validate_template und infraguard_scan für " +"denselben file_path erneut aus." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing must finish with a passing InfraGuard scan for the same " +"file_path." +msgstr "" +"reviewing muss mit einem bestandenen InfraGuard-Scan für denselben " +"file_path abschließen." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"This input still lacks a clear cloud resource, deployment target, or " +"operations constraint; clarify with the user first." +msgstr "" +"Dieser Eingabe fehlt noch eine klare Cloud-Ressource, ein " +"Bereitstellungsziel oder eine Betriebsanforderung; klären Sie dies zuerst" +" mit dem Benutzer." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"The current flow only supports Alibaba Cloud deployment requests; ask the" +" user to change the target to Alibaba Cloud or confirm that it should not" +" be handled for now." +msgstr "" +"Der aktuelle Ablauf unterstützt nur Alibaba-Cloud-" +"Bereitstellungsanfragen; bitten Sie den Benutzer, das Ziel auf Alibaba " +"Cloud zu ändern oder zu bestätigen, dass dies vorerst nicht bearbeitet " +"werden soll." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"Low-confidence intent cannot be completed directly; clarify with the user" +" first." +msgstr "" +"Eine Absicht mit niedriger Konfidenz kann nicht direkt abgeschlossen " +"werden; klären Sie dies zuerst mit dem Benutzer." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"This input is not a deployment or cloud resource request; ask the user to" +" provide a deployment target or confirm that it should not be handled for" +" now." +msgstr "" +"Diese Eingabe ist keine Bereitstellungs- oder Cloud-Ressourcenanfrage; " +"bitten Sie den Benutzer, ein Bereitstellungsziel anzugeben oder zu " +"bestätigen, dass dies vorerst nicht bearbeitet werden soll." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"A successful deployment must wait until ros_deploy returns " +"CREATE_COMPLETE." +msgstr "" +"Eine erfolgreiche Bereitstellung muss warten, bis ros_deploy " +"CREATE_COMPLETE zurückgibt." + #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" "Complete the current step by calling this tool to submit the conclusion. " @@ -3218,24 +3303,73 @@ msgstr "" "{message} complete_step.conclusion muss eines dieser Felder enthalten: " "{fields}." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A conclusion content hash is required before completing the current step." +msgstr "" +"Vor Abschluss des aktuellen Schritts ist ein Hash des Abschlussinhalts " +"erforderlich." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} Completion guard is missing content_field or sha256_field." +msgstr "{message} Dem Abschluss-Guard fehlt content_field oder sha256_field." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} complete_step.conclusion.{field} must be a non-empty string." +msgstr "" +"{message} complete_step.conclusion.{field} muss eine nicht leere " +"Zeichenfolge sein." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} complete_step.conclusion.{field} must include the sha256 hash." +msgstr "{message} complete_step.conclusion.{field} muss den sha256-Hash enthalten." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} complete_step.conclusion.{sha256_field} must equal the sha256 " +"of complete_step.conclusion.{content_field}; actual hash was {actual}." +msgstr "" +"{message} complete_step.conclusion.{sha256_field} muss dem sha256 von " +"complete_step.conclusion.{content_field} entsprechen; der tatsächliche " +"Hash war {actual}." + #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "A successful tool result is required before completing the current step." msgstr "" "Vor dem Abschluss des aktuellen Schritts ist ein erfolgreiches Tool-" "Ergebnis erforderlich." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "the required tool" +msgstr "das erforderliche Tool" + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "" -"{message} complete_step.conclusion.{field} must match the {tool} result " -"value {value}." +"{message} Call {after_tool} first and wait for a successful result before" +" calling {tool}." msgstr "" -"{message} complete_step.conclusion.{field} muss dem Ergebniswert {value} " -"von {tool} entsprechen." +"{message} Rufen Sie zuerst {after_tool} auf und warten Sie auf ein " +"erfolgreiches Ergebnis, bevor Sie {tool} aufrufen." #: src/iac_code/pipeline/engine/complete_step_tool.py -msgid "" -msgstr "" +#, python-brace-format +msgid "" +"{message} The {tool} result field {field} must equal {expected}; actual " +"value was {actual}." +msgstr "" +"{message} Das Ergebnisfeld {field} von {tool} muss {expected} " +"entsprechen; der tatsächliche Wert war {actual}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} The {tool} result must include non-empty field {field}." +msgstr "" +"{message} Das Ergebnis von {tool} muss das nicht leere Feld {field} " +"enthalten." #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -3262,8 +3396,56 @@ msgstr "" "erfolgreiches Ergebnis{status_hint}{success_hint}." #: src/iac_code/pipeline/engine/complete_step_tool.py -msgid "the required tool" -msgstr "das erforderliche Tool" +#, python-brace-format +msgid "" +"{message} The latest matching {tool} result field {field} must equal " +"{expected}; actual value was {actual}." +msgstr "" +"{message} Das Ergebnisfeld {field} des neuesten passenden Ergebnisses von" +" {tool} muss {expected} entsprechen; der tatsächliche Wert war {actual}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} The latest matching {tool} result must include non-empty field " +"{field}." +msgstr "" +"{message} Das neueste passende Ergebnis von {tool} muss das nicht leere " +"Feld {field} enthalten." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} Call {tool} first and wait for a successful result." +msgstr "" +"{message} Rufen Sie zuerst {tool} auf und warten Sie auf ein " +"erfolgreiches Ergebnis." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A write tool" +msgstr "Ein Schreib-Tool" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} {tool} ran after the required validation result for the same " +"target; rerun the required validation before completing the step." +msgstr "" +"{message} {tool} wurde nach dem erforderlichen Validierungsergebnis für " +"dasselbe Ziel ausgeführt; führen Sie die erforderliche Validierung erneut" +" aus, bevor Sie den Schritt abschließen." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} complete_step.conclusion.{field} must match the {tool} result " +"value {value}." +msgstr "" +"{message} complete_step.conclusion.{field} muss dem Ergebniswert {value} " +"von {tool} entsprechen." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +msgstr "" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -3334,6 +3516,116 @@ msgstr "Sitzungsbackup fehlgeschlagen." msgid "Candidate {index}" msgstr "Kandidat {index}" +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "command timed out after {seconds} seconds" +msgstr "Befehl nach {seconds} Sekunden abgelaufen" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Running {command}" +msgstr "{command} wird ausgeführt" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Finished {command}" +msgstr "{command} abgeschlossen" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Version check failed for {name}: {reason}" +msgstr "Versionsprüfung für {name} fehlgeschlagen: {reason}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Could not determine {name} version from output." +msgstr "Die Version von {name} konnte aus der Ausgabe nicht ermittelt werden." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "{name} version {version} is lower than required {minimum}." +msgstr "" +"Die Version {version} von {name} ist niedriger als die erforderliche " +"Version {minimum}." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "" +"No download asset configured for platform {platform}/{architecture} in " +"installer {installer}." +msgstr "" +"Für Plattform {platform}/{architecture} ist im Installer {installer} kein" +" Download-Artefakt konfiguriert." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "No usable download URL configured for installer {installer}." +msgstr "Für Installer {installer} ist keine nutzbare Download-URL konfiguriert." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "download asset {filename} is missing sha256" +msgstr "dem Download-Artefakt {filename} fehlt sha256" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "failed to create install directory {path}: {error}" +msgstr "Installationsverzeichnis {path} konnte nicht erstellt werden: {error}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "failed to install {filename} to {path}: {error}" +msgstr "{filename} konnte nicht nach {path} installiert werden: {error}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}" +msgstr "{filename} wird heruntergeladen" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "incomplete download for {filename}: expected {expected}, got {actual}" +msgstr "" +"unvollständiger Download für {filename}: erwartet {expected}, erhalten " +"{actual}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "sha256 mismatch for {filename}: expected {expected}, got {actual}" +msgstr "" +"sha256 stimmt für {filename} nicht überein: erwartet {expected}, erhalten" +" {actual}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloaded {filename}" +msgstr "{filename} heruntergeladen" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}: {percent} ({downloaded} / {total})" +msgstr "{filename} wird heruntergeladen: {percent} ({downloaded} / {total})" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}: {downloaded} downloaded" +msgstr "{filename} wird heruntergeladen: {downloaded} heruntergeladen" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "download failed: {error_type}" +msgstr "Download fehlgeschlagen: {error_type}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "exit code {returncode}" +msgstr "Exit-Code {returncode}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "{command} exited with {returncode}: {body}" +msgstr "{command} wurde mit {returncode} beendet: {body}" + #: src/iac_code/pipeline/engine/show_diagram_tool.py msgid "ECS instance" msgstr "ECS-Instanz" @@ -3431,6 +3723,140 @@ msgstr "Der Template-Dateipfad darf das Arbeitsverzeichnis nicht verlassen" msgid "[Image input]" msgstr "[Bildeingabe]" +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "error" +msgstr "Fehler" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "passed" +msgstr "bestanden" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +msgid "failed" +msgstr "fehlgeschlagen" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +msgid "completed" +msgstr "abgeschlossen" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "InfraGuard CLI does not support --no-waivers" +msgstr "Die InfraGuard-CLI unterstützt --no-waivers nicht" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "1 finding" +msgstr "1 Befund" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "{count} findings" +msgstr "{count} Befunde" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "line {line}" +msgstr "Zeile {line}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Reason: {reason}" +msgstr "Grund: {reason}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Recommendation: {recommendation}" +msgstr "Empfehlung: {recommendation}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Snippet: {snippet}" +msgstr "Ausschnitt: {snippet}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "blocking {count}" +msgstr "{count} blockierend" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Command: {command}" +msgstr "Befehl: {command}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Status: {status}" +msgstr "Zustand: {status}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "File: {file_path}" +msgstr "Datei: {file_path}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Mode: {mode}" +msgstr "Modus: {mode}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Exit code: {exit_code}" +msgstr "Exit-Code: {exit_code}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Ignore waivers: {value}" +msgstr "Ausnahmen ignorieren: {value}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Blocking severities: {severities}" +msgstr "Blockierende Schweregrade: {severities}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Blocking findings: {count}" +msgstr "Blockierende Befunde: {count}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Aspects: {aspects}" +msgstr "Aspekte: {aspects}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Policies:" +msgstr "Richtlinien:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Summary:" +msgstr "Zusammenfassung:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Severity counts: {counts}" +msgstr "Anzahl nach Schweregrad: {counts}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Stderr: {stderr}" +msgstr "Standardfehler: {stderr}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Findings:" +msgstr "Befunde:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "No findings." +msgstr "Keine Befunde." + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Run InfraGuard static scan and return structured JSON results." +msgstr "" +"Führt einen statischen InfraGuard-Scan aus und gibt strukturierte JSON-" +"Ergebnisse zurück." + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py msgid "CIDR block overlapped" msgstr "CIDR-Block überschneidet sich" @@ -4943,6 +5369,64 @@ msgstr "Nein, immer \"{rule}\" ablehnen (diese Sitzung)" msgid "No, always reject this tool" msgstr "Nein, dieses Tool immer ablehnen" +#: src/iac_code/ui/repl.py +msgid "Installing prerequisites..." +msgstr "Voraussetzungen werden installiert..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Running: {command}" +msgstr "Wird ausgeführt: {command}" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Done: {command}" +msgstr "Fertig: {command}" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Failed: {command}" +msgstr "Fehlgeschlagen: {command}" + +#: src/iac_code/ui/repl.py +msgid "Downloading prerequisites..." +msgstr "Voraussetzungen werden heruntergeladen..." + +#: src/iac_code/ui/repl.py +msgid "Initializing prerequisites..." +msgstr "Voraussetzungen werden initialisiert..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Installing prerequisites with {installer}..." +msgstr "Voraussetzungen werden mit {installer} installiert..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Download: {percent} ({downloaded} / {total})" +msgstr "Download: {percent} ({downloaded} / {total})" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Download: {downloaded} downloaded" +msgstr "Download: {downloaded} heruntergeladen" + +#: src/iac_code/ui/repl.py +msgid "review step" +msgstr "Review-Schritt" + +#: src/iac_code/ui/repl.py +msgid "Direct binary download" +msgstr "Direkter Binärdownload" + +#: src/iac_code/ui/repl.py +msgid "Go install" +msgstr "Installation mit Go" + +#: src/iac_code/ui/repl.py +msgid "Homebrew" +msgstr "Homebrew-Paketmanager" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Approve project MCP server {server!r} from {source}? [y/N] " @@ -5126,18 +5610,10 @@ msgstr "" "↺ Rollback-Bereinigung fortsetzen: Alle {count} Datensätze sind " "abgeschlossen." -#: src/iac_code/ui/repl.py -msgid "failed" -msgstr "fehlgeschlagen" - #: src/iac_code/ui/repl.py msgid "in progress" msgstr "in Bearbeitung" -#: src/iac_code/ui/repl.py -msgid "completed" -msgstr "abgeschlossen" - #: src/iac_code/ui/repl.py msgid "skipped" msgstr "übersprungen" @@ -5168,6 +5644,46 @@ msgstr "" "Rollback-Bereinigungsressourcen wurden erkannt, aber das Einfügen des " "Bereinigungs-Prompts ist fehlgeschlagen." +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline prerequisite {name} is missing and no configured installer is " +"usable; {feature} will be skipped for this run." +msgstr "" +"Die Pipeline-Voraussetzung {name} fehlt und kein konfigurierter Installer" +" ist verwendbar; {feature} wird für diesen Lauf übersprungen." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline prerequisite {name} is missing; it is required for {feature}. " +"Choose an installer or skip this feature for this run." +msgstr "" +"Die Pipeline-Voraussetzung {name} fehlt; sie ist für {feature} " +"erforderlich. Wählen Sie einen Installer oder überspringen Sie diese " +"Funktion für diesen Lauf." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Skip {feature} for this run" +msgstr "{feature} für diesen Lauf überspringen" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"{feature} skipped\n" +"Prerequisite {name} failed, so {feature} is disabled for this run.\n" +"Reason: {reason}" +msgstr "" +"{feature} übersprungen\n" +"Voraussetzung {name} ist fehlgeschlagen, daher ist {feature} für diesen " +"Lauf deaktiviert.\n" +"Grund: {reason}" + +#: src/iac_code/ui/repl.py +msgid "configured feature" +msgstr "konfigurierte Funktion" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Ignoring saved pipeline state: {reason}" @@ -5265,11 +5781,6 @@ msgstr "" "✎ Hinzugefügt zu {target}\n" " Sie sagten: {user_msg}" -#: src/iac_code/ui/repl.py -#, python-brace-format -msgid "Reason: {reason}" -msgstr "Grund: {reason}" - #: src/iac_code/ui/repl.py #, python-brace-format msgid "" @@ -6031,24 +6542,6 @@ msgstr "Öffnen einer nicht regulären Datei verweigert: {path}" #~ msgid "Resume a session by ID" #~ msgstr "Eine Sitzung anhand der ID fortsetzen" -#~ msgid "sed in-place edit requires confirmation" -#~ msgstr "sed-In-Place-Bearbeitung erfordert Bestätigung" - -#~ msgid "{n} more line{s}" -#~ msgstr "{n} weitere Zeile{s}" - -#~ msgid "{n} message{s}" -#~ msgstr "{n} Nachricht{s}" - -#~ msgid "{n} minute{s} ago" -#~ msgstr "vor {n} Minute{s}" - -#~ msgid "{n} hour{s} ago" -#~ msgstr "vor {n} Stunde{s}" - -#~ msgid "{n} day{s} ago" -#~ msgstr "vor {n} Tag{s}" - #~ msgid "Project Memory Index" #~ msgstr "Projektspeicherindex" @@ -6256,3 +6749,9 @@ msgstr "Öffnen einer nicht regulären Datei verweigert: {path}" #~ "Hinweis: Bilder werden im Pipeline-Modus" #~ " nicht unterstützt und ignoriert." +#~ msgid "review" +#~ msgstr "Review" + +#~ msgid "feature" +#~ msgstr "Funktion" + diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 79e1c9e4..23e5e319 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -614,11 +614,12 @@ msgstr "" " Documentación: https://aliyun.github.io/iac-" "code/es/docs/configuration/authentication\n" -#: src/iac_code/cli/headless.py src/iac_code/ui/renderer.py -#: src/iac_code/ui/repl.py +#: src/iac_code/cli/headless.py +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/renderer.py src/iac_code/ui/repl.py #, python-brace-format msgid "Error: {error}" -msgstr "Error: {error}" +msgstr "Se produjo un error: {error}" #: src/iac_code/cli/headless.py src/iac_code/ui/repl.py #, python-brace-format @@ -2954,6 +2955,10 @@ msgstr "Estimación de costo ROS" msgid "ROS Deploy" msgstr "Despliegue de ROS" +#: src/iac_code/pipeline/display_names.py +msgid "InfraGuard scan" +msgstr "Escaneo de InfraGuard" + #: src/iac_code/pipeline/engine/ask_user_question_tool.py msgid "" "Pipeline-only tool that asks the user to choose an option or type " @@ -3111,6 +3116,85 @@ msgstr "" "Se detectaron {count} recursos de limpieza de rollback; iniciando " "limpieza." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing ran write_file/edit_file after ros_validate_template; rerun " +"ros_validate_template and infraguard_scan for the same file_path." +msgstr "" +"reviewing ejecutó write_file/edit_file después de ros_validate_template; " +"vuelve a ejecutar ros_validate_template e infraguard_scan para el mismo " +"file_path." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing must validate the repaired template with ros_validate_template " +"for the same file_path." +msgstr "" +"reviewing debe validar la plantilla reparada con ros_validate_template " +"para el mismo file_path." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing ran write_file/edit_file after the final InfraGuard scan; rerun" +" ros_validate_template and infraguard_scan for the same file_path." +msgstr "" +"reviewing ejecutó write_file/edit_file después del escaneo final de " +"InfraGuard; vuelve a ejecutar ros_validate_template e infraguard_scan " +"para el mismo file_path." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing must finish with a passing InfraGuard scan for the same " +"file_path." +msgstr "" +"reviewing debe finalizar con un escaneo InfraGuard aprobado para el mismo" +" file_path." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"This input still lacks a clear cloud resource, deployment target, or " +"operations constraint; clarify with the user first." +msgstr "" +"A esta entrada todavía le falta un recurso de nube, un destino de " +"despliegue o una restricción operativa claros; aclara esto primero con el" +" usuario." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"The current flow only supports Alibaba Cloud deployment requests; ask the" +" user to change the target to Alibaba Cloud or confirm that it should not" +" be handled for now." +msgstr "" +"El flujo actual solo admite solicitudes de despliegue en Alibaba Cloud; " +"pide al usuario que cambie el destino a Alibaba Cloud o confirme que no " +"debe gestionarse por ahora." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"Low-confidence intent cannot be completed directly; clarify with the user" +" first." +msgstr "" +"Una intención de baja confianza no puede completarse directamente; aclara" +" esto primero con el usuario." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"This input is not a deployment or cloud resource request; ask the user to" +" provide a deployment target or confirm that it should not be handled for" +" now." +msgstr "" +"Esta entrada no es una solicitud de despliegue ni de recursos de nube; " +"pide al usuario que proporcione un destino de despliegue o confirme que " +"no debe gestionarse por ahora." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"A successful deployment must wait until ros_deploy returns " +"CREATE_COMPLETE." +msgstr "" +"Un despliegue correcto debe esperar hasta que ros_deploy devuelva " +"CREATE_COMPLETE." + #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" "Complete the current step by calling this tool to submit the conclusion. " @@ -3211,24 +3295,69 @@ msgstr "" "{message} complete_step.conclusion debe incluir uno de estos campos: " "{fields}." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A conclusion content hash is required before completing the current step." +msgstr "" +"Se requiere el hash del contenido de la conclusión antes de completar el " +"paso actual." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} Completion guard is missing content_field or sha256_field." +msgstr "{message} La guarda de finalización no tiene content_field o sha256_field." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} complete_step.conclusion.{field} must be a non-empty string." +msgstr "{message} complete_step.conclusion.{field} debe ser una cadena no vacía." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} complete_step.conclusion.{field} must include the sha256 hash." +msgstr "{message} complete_step.conclusion.{field} debe incluir el hash sha256." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} complete_step.conclusion.{sha256_field} must equal the sha256 " +"of complete_step.conclusion.{content_field}; actual hash was {actual}." +msgstr "" +"{message} complete_step.conclusion.{sha256_field} debe ser igual al " +"sha256 de complete_step.conclusion.{content_field}; el hash real fue " +"{actual}." + #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "A successful tool result is required before completing the current step." msgstr "" "Se requiere un resultado de herramienta correcto antes de completar el " "paso actual." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "the required tool" +msgstr "la herramienta requerida" + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "" -"{message} complete_step.conclusion.{field} must match the {tool} result " -"value {value}." +"{message} Call {after_tool} first and wait for a successful result before" +" calling {tool}." msgstr "" -"{message} complete_step.conclusion.{field} debe coincidir con el valor " -"{value} del resultado de {tool}." +"{message} Llama primero a {after_tool} y espera un resultado correcto " +"antes de llamar a {tool}." #: src/iac_code/pipeline/engine/complete_step_tool.py -msgid "" -msgstr "" +#, python-brace-format +msgid "" +"{message} The {tool} result field {field} must equal {expected}; actual " +"value was {actual}." +msgstr "" +"{message} El campo {field} del resultado de {tool} debe ser igual a " +"{expected}; el valor real fue {actual}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} The {tool} result must include non-empty field {field}." +msgstr "{message} El resultado de {tool} debe incluir el campo no vacío {field}." #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -3255,8 +3384,54 @@ msgstr "" "correcto{status_hint}{success_hint}." #: src/iac_code/pipeline/engine/complete_step_tool.py -msgid "the required tool" -msgstr "la herramienta requerida" +#, python-brace-format +msgid "" +"{message} The latest matching {tool} result field {field} must equal " +"{expected}; actual value was {actual}." +msgstr "" +"{message} El campo {field} del último resultado coincidente de {tool} " +"debe ser igual a {expected}; el valor real fue {actual}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} The latest matching {tool} result must include non-empty field " +"{field}." +msgstr "" +"{message} El último resultado coincidente de {tool} debe incluir el campo" +" no vacío {field}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} Call {tool} first and wait for a successful result." +msgstr "{message} Llama primero a {tool} y espera un resultado correcto." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A write tool" +msgstr "Una herramienta de escritura" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} {tool} ran after the required validation result for the same " +"target; rerun the required validation before completing the step." +msgstr "" +"{message} {tool} se ejecutó después del resultado de validación requerido" +" para el mismo destino; vuelve a ejecutar la validación requerida antes " +"de completar el paso." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} complete_step.conclusion.{field} must match the {tool} result " +"value {value}." +msgstr "" +"{message} complete_step.conclusion.{field} debe coincidir con el valor " +"{value} del resultado de {tool}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +msgstr "" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -3324,6 +3499,116 @@ msgstr "Error en la copia de seguridad de la sesión." msgid "Candidate {index}" msgstr "Candidato {index}" +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "command timed out after {seconds} seconds" +msgstr "El comando agotó el tiempo de espera tras {seconds} segundos" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Running {command}" +msgstr "Ejecutando {command}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Finished {command}" +msgstr "Finalizado {command}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Version check failed for {name}: {reason}" +msgstr "La comprobación de versión de {name} falló: {reason}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Could not determine {name} version from output." +msgstr "No se pudo determinar la versión de {name} a partir de la salida." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "{name} version {version} is lower than required {minimum}." +msgstr "La versión {version} de {name} es inferior a la requerida {minimum}." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "" +"No download asset configured for platform {platform}/{architecture} in " +"installer {installer}." +msgstr "" +"No hay ningún activo de descarga configurado para la plataforma " +"{platform}/{architecture} en el instalador {installer}." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "No usable download URL configured for installer {installer}." +msgstr "" +"No hay ninguna URL de descarga utilizable configurada para el instalador " +"{installer}." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "download asset {filename} is missing sha256" +msgstr "al activo de descarga {filename} le falta sha256" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "failed to create install directory {path}: {error}" +msgstr "no se pudo crear el directorio de instalación {path}: {error}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "failed to install {filename} to {path}: {error}" +msgstr "no se pudo instalar {filename} en {path}: {error}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}" +msgstr "Descargando {filename}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "incomplete download for {filename}: expected {expected}, got {actual}" +msgstr "" +"descarga incompleta de {filename}: se esperaba {expected}, se obtuvo " +"{actual}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "sha256 mismatch for {filename}: expected {expected}, got {actual}" +msgstr "" +"sha256 no coincide para {filename}: se esperaba {expected}, se obtuvo " +"{actual}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloaded {filename}" +msgstr "Descargado {filename}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}: {percent} ({downloaded} / {total})" +msgstr "Descargando {filename}: {percent} ({downloaded} / {total})" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}: {downloaded} downloaded" +msgstr "Descargando {filename}: {downloaded} descargados" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "download failed: {error_type}" +msgstr "descarga fallida: {error_type}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "exit code {returncode}" +msgstr "código de salida {returncode}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "{command} exited with {returncode}: {body}" +msgstr "{command} terminó con {returncode}: {body}" + #: src/iac_code/pipeline/engine/show_diagram_tool.py msgid "ECS instance" msgstr "Instancia ECS" @@ -3423,6 +3708,140 @@ msgstr "La ruta del archivo de plantilla no puede salir del directorio de trabaj msgid "[Image input]" msgstr "[Entrada de imagen]" +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "error" +msgstr "con error" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "passed" +msgstr "aprobado" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +msgid "failed" +msgstr "fallido" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +msgid "completed" +msgstr "completado" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "InfraGuard CLI does not support --no-waivers" +msgstr "La CLI de InfraGuard no admite --no-waivers" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "1 finding" +msgstr "1 hallazgo" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "{count} findings" +msgstr "{count} hallazgos" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "line {line}" +msgstr "línea {line}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Reason: {reason}" +msgstr "Motivo: {reason}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Recommendation: {recommendation}" +msgstr "Recomendación: {recommendation}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Snippet: {snippet}" +msgstr "Fragmento: {snippet}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "blocking {count}" +msgstr "{count} bloqueantes" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Command: {command}" +msgstr "Comando: {command}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Status: {status}" +msgstr "Estado: {status}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "File: {file_path}" +msgstr "Archivo: {file_path}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Mode: {mode}" +msgstr "Modo: {mode}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Exit code: {exit_code}" +msgstr "Código de salida: {exit_code}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Ignore waivers: {value}" +msgstr "Ignorar exenciones: {value}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Blocking severities: {severities}" +msgstr "Severidades bloqueantes: {severities}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Blocking findings: {count}" +msgstr "Hallazgos bloqueantes: {count}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Aspects: {aspects}" +msgstr "Aspectos: {aspects}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Policies:" +msgstr "Políticas:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Summary:" +msgstr "Resumen:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Severity counts: {counts}" +msgstr "Conteo por severidad: {counts}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Stderr: {stderr}" +msgstr "Error estándar: {stderr}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Findings:" +msgstr "Hallazgos:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "No findings." +msgstr "Sin hallazgos." + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Run InfraGuard static scan and return structured JSON results." +msgstr "" +"Ejecuta un escaneo estático de InfraGuard y devuelve resultados JSON " +"estructurados." + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py msgid "CIDR block overlapped" msgstr "Bloque CIDR superpuesto" @@ -4936,6 +5355,64 @@ msgstr "No, siempre denegar \"{rule}\" (esta sesión)" msgid "No, always reject this tool" msgstr "No, rechazar siempre esta herramienta" +#: src/iac_code/ui/repl.py +msgid "Installing prerequisites..." +msgstr "Instalando requisitos previos..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Running: {command}" +msgstr "Ejecutando: {command}" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Done: {command}" +msgstr "Completado: {command}" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Failed: {command}" +msgstr "Falló: {command}" + +#: src/iac_code/ui/repl.py +msgid "Downloading prerequisites..." +msgstr "Descargando requisitos previos..." + +#: src/iac_code/ui/repl.py +msgid "Initializing prerequisites..." +msgstr "Inicializando requisitos previos..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Installing prerequisites with {installer}..." +msgstr "Instalando requisitos previos con {installer}..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Download: {percent} ({downloaded} / {total})" +msgstr "Descarga: {percent} ({downloaded} / {total})" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Download: {downloaded} downloaded" +msgstr "Descarga: {downloaded} descargados" + +#: src/iac_code/ui/repl.py +msgid "review step" +msgstr "paso de revisión" + +#: src/iac_code/ui/repl.py +msgid "Direct binary download" +msgstr "Descarga binaria directa" + +#: src/iac_code/ui/repl.py +msgid "Go install" +msgstr "Instalación con Go" + +#: src/iac_code/ui/repl.py +msgid "Homebrew" +msgstr "Homebrew (gestor de paquetes)" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Approve project MCP server {server!r} from {source}? [y/N] " @@ -5124,18 +5601,10 @@ msgstr "" "↺ Reanudación de limpieza de rollback: los {count} registros están " "completados." -#: src/iac_code/ui/repl.py -msgid "failed" -msgstr "fallido" - #: src/iac_code/ui/repl.py msgid "in progress" msgstr "en curso" -#: src/iac_code/ui/repl.py -msgid "completed" -msgstr "completado" - #: src/iac_code/ui/repl.py msgid "skipped" msgstr "omitido" @@ -5166,6 +5635,45 @@ msgstr "" "Se detectaron recursos de limpieza de rollback, pero falló la inyección " "del prompt de limpieza." +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline prerequisite {name} is missing and no configured installer is " +"usable; {feature} will be skipped for this run." +msgstr "" +"Falta el prerrequisito de pipeline {name} y no hay ningún instalador " +"configurado utilizable; se omitirá {feature} en esta ejecución." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline prerequisite {name} is missing; it is required for {feature}. " +"Choose an installer or skip this feature for this run." +msgstr "" +"Falta el prerrequisito de pipeline {name}; es necesario para {feature}. " +"Elige un instalador u omite esta función en esta ejecución." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Skip {feature} for this run" +msgstr "Omitir {feature} en esta ejecución" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"{feature} skipped\n" +"Prerequisite {name} failed, so {feature} is disabled for this run.\n" +"Reason: {reason}" +msgstr "" +"{feature} omitido\n" +"El prerrequisito {name} falló, por lo que {feature} está deshabilitado en" +" esta ejecución.\n" +"Motivo: {reason}" + +#: src/iac_code/ui/repl.py +msgid "configured feature" +msgstr "función configurada" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Ignoring saved pipeline state: {reason}" @@ -5261,11 +5769,6 @@ msgstr "" "✎ Añadido a {target}\n" " Dijiste: {user_msg}" -#: src/iac_code/ui/repl.py -#, python-brace-format -msgid "Reason: {reason}" -msgstr "Motivo: {reason}" - #: src/iac_code/ui/repl.py #, python-brace-format msgid "" @@ -6025,24 +6528,6 @@ msgstr "Se rechazó abrir un archivo no regular: {path}" #~ msgid "Resume a session by ID" #~ msgstr "Reanudar una sesión por ID" -#~ msgid "sed in-place edit requires confirmation" -#~ msgstr "La edición sed in situ requiere confirmación" - -#~ msgid "{n} more line{s}" -#~ msgstr "{n} línea{s} más" - -#~ msgid "{n} message{s}" -#~ msgstr "{n} mensaje{s}" - -#~ msgid "{n} minute{s} ago" -#~ msgstr "hace {n} minuto{s}" - -#~ msgid "{n} hour{s} ago" -#~ msgstr "hace {n} hora{s}" - -#~ msgid "{n} day{s} ago" -#~ msgstr "hace {n} día{s}" - #~ msgid "Project Memory Index" #~ msgstr "Índice de memoria del proyecto" @@ -6241,3 +6726,9 @@ msgstr "Se rechazó abrir un archivo no regular: {path}" #~ " con el modo pipeline y se " #~ "ignorarán." +#~ msgid "review" +#~ msgstr "revisión" + +#~ msgid "feature" +#~ msgstr "función" + diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index fd46e434..59d858b8 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -609,8 +609,9 @@ msgstr "" " Documentation : https://aliyun.github.io/iac-" "code/fr/docs/configuration/authentication\n" -#: src/iac_code/cli/headless.py src/iac_code/ui/renderer.py -#: src/iac_code/ui/repl.py +#: src/iac_code/cli/headless.py +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/renderer.py src/iac_code/ui/repl.py #, python-brace-format msgid "Error: {error}" msgstr "Erreur : {error}" @@ -2961,6 +2962,10 @@ msgstr "Estimation du coût ROS" msgid "ROS Deploy" msgstr "Déploiement ROS" +#: src/iac_code/pipeline/display_names.py +msgid "InfraGuard scan" +msgstr "Analyse InfraGuard" + #: src/iac_code/pipeline/engine/ask_user_question_tool.py msgid "" "Pipeline-only tool that asks the user to choose an option or type " @@ -3120,6 +3125,85 @@ msgstr "" "{count} ressources de nettoyage du rollback détectées ; démarrage du " "nettoyage." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing ran write_file/edit_file after ros_validate_template; rerun " +"ros_validate_template and infraguard_scan for the same file_path." +msgstr "" +"reviewing a exécuté write_file/edit_file après ros_validate_template ; " +"réexécutez ros_validate_template et infraguard_scan pour le même " +"file_path." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing must validate the repaired template with ros_validate_template " +"for the same file_path." +msgstr "" +"reviewing doit valider le modèle réparé avec ros_validate_template pour " +"le même file_path." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing ran write_file/edit_file after the final InfraGuard scan; rerun" +" ros_validate_template and infraguard_scan for the same file_path." +msgstr "" +"reviewing a exécuté write_file/edit_file après le dernier scan InfraGuard" +" ; réexécutez ros_validate_template et infraguard_scan pour le même " +"file_path." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing must finish with a passing InfraGuard scan for the same " +"file_path." +msgstr "" +"reviewing doit se terminer par un scan InfraGuard réussi pour le même " +"file_path." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"This input still lacks a clear cloud resource, deployment target, or " +"operations constraint; clarify with the user first." +msgstr "" +"Cette entrée ne précise pas encore clairement la ressource cloud, la " +"cible de déploiement ou la contrainte d’exploitation ; clarifiez d’abord " +"avec l’utilisateur." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"The current flow only supports Alibaba Cloud deployment requests; ask the" +" user to change the target to Alibaba Cloud or confirm that it should not" +" be handled for now." +msgstr "" +"Le flux actuel ne prend en charge que les demandes de déploiement Alibaba" +" Cloud ; demandez à l’utilisateur de choisir Alibaba Cloud comme cible ou" +" de confirmer que la demande ne doit pas être traitée pour l’instant." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"Low-confidence intent cannot be completed directly; clarify with the user" +" first." +msgstr "" +"Une intention à faible confiance ne peut pas être finalisée directement ;" +" clarifiez d’abord avec l’utilisateur." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"This input is not a deployment or cloud resource request; ask the user to" +" provide a deployment target or confirm that it should not be handled for" +" now." +msgstr "" +"Cette entrée n’est pas une demande de déploiement ni de ressource cloud ;" +" demandez à l’utilisateur de fournir une cible de déploiement ou de " +"confirmer que la demande ne doit pas être traitée pour l’instant." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"A successful deployment must wait until ros_deploy returns " +"CREATE_COMPLETE." +msgstr "" +"Un déploiement réussi doit attendre que ros_deploy retourne " +"CREATE_COMPLETE." + #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" "Complete the current step by calling this tool to submit the conclusion. " @@ -3218,22 +3302,69 @@ msgstr "" "{message} complete_step.conclusion doit inclure l’un de ces champs : " "{fields}." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A conclusion content hash is required before completing the current step." +msgstr "" +"Un hash du contenu de la conclusion est requis avant de terminer l’étape " +"actuelle." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} Completion guard is missing content_field or sha256_field." +msgstr "" +"{message} La garde de complétion ne définit pas content_field ou " +"sha256_field." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} complete_step.conclusion.{field} must be a non-empty string." +msgstr "{message} complete_step.conclusion.{field} doit être une chaîne non vide." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} complete_step.conclusion.{field} must include the sha256 hash." +msgstr "{message} complete_step.conclusion.{field} doit inclure le hash sha256." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} complete_step.conclusion.{sha256_field} must equal the sha256 " +"of complete_step.conclusion.{content_field}; actual hash was {actual}." +msgstr "" +"{message} complete_step.conclusion.{sha256_field} doit être égal au " +"sha256 de complete_step.conclusion.{content_field} ; le hash réel était " +"{actual}." + #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "A successful tool result is required before completing the current step." msgstr "Un résultat d’outil réussi est requis avant de terminer l’étape actuelle." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "the required tool" +msgstr "l’outil requis" + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "" -"{message} complete_step.conclusion.{field} must match the {tool} result " -"value {value}." +"{message} Call {after_tool} first and wait for a successful result before" +" calling {tool}." msgstr "" -"{message} complete_step.conclusion.{field} doit correspondre à la valeur " -"de résultat {value} de {tool}." +"{message} Appelez d’abord {after_tool} et attendez un résultat réussi " +"avant d’appeler {tool}." #: src/iac_code/pipeline/engine/complete_step_tool.py -msgid "" -msgstr "" +#, python-brace-format +msgid "" +"{message} The {tool} result field {field} must equal {expected}; actual " +"value was {actual}." +msgstr "" +"{message} Le champ {field} du résultat de {tool} doit être égal à " +"{expected} ; la valeur réelle était {actual}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} The {tool} result must include non-empty field {field}." +msgstr "{message} Le résultat de {tool} doit inclure le champ non vide {field}." #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -3260,8 +3391,54 @@ msgstr "" "réussi{status_hint}{success_hint}." #: src/iac_code/pipeline/engine/complete_step_tool.py -msgid "the required tool" -msgstr "l’outil requis" +#, python-brace-format +msgid "" +"{message} The latest matching {tool} result field {field} must equal " +"{expected}; actual value was {actual}." +msgstr "" +"{message} Le champ {field} du dernier résultat correspondant de {tool} " +"doit être égal à {expected} ; la valeur réelle était {actual}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} The latest matching {tool} result must include non-empty field " +"{field}." +msgstr "" +"{message} Le dernier résultat correspondant de {tool} doit inclure le " +"champ non vide {field}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} Call {tool} first and wait for a successful result." +msgstr "{message} Appelez d’abord {tool} et attendez un résultat réussi." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A write tool" +msgstr "Un outil d’écriture" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} {tool} ran after the required validation result for the same " +"target; rerun the required validation before completing the step." +msgstr "" +"{message} {tool} s’est exécuté après le résultat de validation requis " +"pour la même cible ; relancez la validation requise avant de terminer " +"l’étape." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} complete_step.conclusion.{field} must match the {tool} result " +"value {value}." +msgstr "" +"{message} complete_step.conclusion.{field} doit correspondre à la valeur " +"de résultat {value} de {tool}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +msgstr "" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -3331,6 +3508,118 @@ msgstr "Échec de la sauvegarde de session." msgid "Candidate {index}" msgstr "Candidat {index}" +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "command timed out after {seconds} seconds" +msgstr "La commande a expiré après {seconds} secondes" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Running {command}" +msgstr "Exécution de {command}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Finished {command}" +msgstr "{command} terminé" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Version check failed for {name}: {reason}" +msgstr "La vérification de version de {name} a échoué : {reason}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Could not determine {name} version from output." +msgstr "Impossible de déterminer la version de {name} à partir de la sortie." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "{name} version {version} is lower than required {minimum}." +msgstr "" +"La version {version} de {name} est inférieure à la version requise " +"{minimum}." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "" +"No download asset configured for platform {platform}/{architecture} in " +"installer {installer}." +msgstr "" +"Aucun artefact de téléchargement n’est configuré pour la plateforme " +"{platform}/{architecture} dans l’installateur {installer}." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "No usable download URL configured for installer {installer}." +msgstr "" +"Aucune URL de téléchargement utilisable n’est configurée pour " +"l’installateur {installer}." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "download asset {filename} is missing sha256" +msgstr "l’artefact de téléchargement {filename} n’a pas de sha256" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "failed to create install directory {path}: {error}" +msgstr "échec de la création du répertoire d’installation {path} : {error}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "failed to install {filename} to {path}: {error}" +msgstr "échec de l’installation de {filename} dans {path} : {error}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}" +msgstr "Téléchargement de {filename}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "incomplete download for {filename}: expected {expected}, got {actual}" +msgstr "" +"téléchargement incomplet pour {filename} : attendu {expected}, obtenu " +"{actual}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "sha256 mismatch for {filename}: expected {expected}, got {actual}" +msgstr "" +"sha256 non concordant pour {filename} : attendu {expected}, obtenu " +"{actual}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloaded {filename}" +msgstr "{filename} téléchargé" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}: {percent} ({downloaded} / {total})" +msgstr "Téléchargement de {filename} : {percent} ({downloaded} / {total})" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}: {downloaded} downloaded" +msgstr "Téléchargement de {filename} : {downloaded} téléchargés" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "download failed: {error_type}" +msgstr "échec du téléchargement : {error_type}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "exit code {returncode}" +msgstr "code de sortie {returncode}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "{command} exited with {returncode}: {body}" +msgstr "{command} s’est terminé avec {returncode} : {body}" + #: src/iac_code/pipeline/engine/show_diagram_tool.py msgid "ECS instance" msgstr "Instance ECS" @@ -3428,6 +3717,140 @@ msgstr "Le chemin du fichier de modèle ne peut pas sortir du répertoire de tra msgid "[Image input]" msgstr "[Entrée d'image]" +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "error" +msgstr "erreur" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "passed" +msgstr "réussi" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +msgid "failed" +msgstr "échoué" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +msgid "completed" +msgstr "terminé" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "InfraGuard CLI does not support --no-waivers" +msgstr "La CLI InfraGuard ne prend pas en charge --no-waivers" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "1 finding" +msgstr "1 constat" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "{count} findings" +msgstr "{count} constats" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "line {line}" +msgstr "ligne {line}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Reason: {reason}" +msgstr "Raison : {reason}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Recommendation: {recommendation}" +msgstr "Recommandation : {recommendation}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Snippet: {snippet}" +msgstr "Extrait : {snippet}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "blocking {count}" +msgstr "{count} bloquants" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Command: {command}" +msgstr "Commande : {command}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Status: {status}" +msgstr "Statut : {status}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "File: {file_path}" +msgstr "Fichier : {file_path}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Mode: {mode}" +msgstr "Mode : {mode}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Exit code: {exit_code}" +msgstr "Code de sortie : {exit_code}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Ignore waivers: {value}" +msgstr "Ignorer les dérogations : {value}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Blocking severities: {severities}" +msgstr "Sévérités bloquantes : {severities}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Blocking findings: {count}" +msgstr "Constats bloquants : {count}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Aspects: {aspects}" +msgstr "Aspects : {aspects}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Policies:" +msgstr "Politiques :" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Summary:" +msgstr "Résumé :" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Severity counts: {counts}" +msgstr "Nombre par sévérité : {counts}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Stderr: {stderr}" +msgstr "Erreur standard : {stderr}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Findings:" +msgstr "Constats :" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "No findings." +msgstr "Aucun constat." + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Run InfraGuard static scan and return structured JSON results." +msgstr "" +"Exécute une analyse statique InfraGuard et renvoie des résultats JSON " +"structurés." + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py msgid "CIDR block overlapped" msgstr "Bloc CIDR chevauchant" @@ -4950,6 +5373,64 @@ msgstr "Non, toujours refuser \"{rule}\" (cette session)" msgid "No, always reject this tool" msgstr "Non, toujours refuser cet outil" +#: src/iac_code/ui/repl.py +msgid "Installing prerequisites..." +msgstr "Installation des prérequis..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Running: {command}" +msgstr "Exécution : {command}" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Done: {command}" +msgstr "Terminé : {command}" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Failed: {command}" +msgstr "Échec : {command}" + +#: src/iac_code/ui/repl.py +msgid "Downloading prerequisites..." +msgstr "Téléchargement des prérequis..." + +#: src/iac_code/ui/repl.py +msgid "Initializing prerequisites..." +msgstr "Initialisation des prérequis..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Installing prerequisites with {installer}..." +msgstr "Installation des prérequis avec {installer}..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Download: {percent} ({downloaded} / {total})" +msgstr "Téléchargement : {percent} ({downloaded} / {total})" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Download: {downloaded} downloaded" +msgstr "Téléchargement : {downloaded} téléchargés" + +#: src/iac_code/ui/repl.py +msgid "review step" +msgstr "étape de revue" + +#: src/iac_code/ui/repl.py +msgid "Direct binary download" +msgstr "Téléchargement binaire direct" + +#: src/iac_code/ui/repl.py +msgid "Go install" +msgstr "Installation avec Go" + +#: src/iac_code/ui/repl.py +msgid "Homebrew" +msgstr "Homebrew (gestionnaire de paquets)" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Approve project MCP server {server!r} from {source}? [y/N] " @@ -5137,18 +5618,10 @@ msgstr "" "↺ Reprise du nettoyage du rollback : les {count} enregistrements sont " "terminés." -#: src/iac_code/ui/repl.py -msgid "failed" -msgstr "échoué" - #: src/iac_code/ui/repl.py msgid "in progress" msgstr "en cours" -#: src/iac_code/ui/repl.py -msgid "completed" -msgstr "terminé" - #: src/iac_code/ui/repl.py msgid "skipped" msgstr "ignoré" @@ -5179,6 +5652,46 @@ msgstr "" "Des ressources de nettoyage du rollback ont été détectées, mais " "l’injection du prompt de nettoyage a échoué." +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline prerequisite {name} is missing and no configured installer is " +"usable; {feature} will be skipped for this run." +msgstr "" +"Le prérequis de pipeline {name} est manquant et aucun installateur " +"configuré n’est utilisable ; {feature} sera ignoré pour cette exécution." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline prerequisite {name} is missing; it is required for {feature}. " +"Choose an installer or skip this feature for this run." +msgstr "" +"Le prérequis de pipeline {name} est manquant ; il est requis pour " +"{feature}. Choisissez un installateur ou ignorez cette fonctionnalité " +"pour cette exécution." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Skip {feature} for this run" +msgstr "Ignorer {feature} pour cette exécution" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"{feature} skipped\n" +"Prerequisite {name} failed, so {feature} is disabled for this run.\n" +"Reason: {reason}" +msgstr "" +"{feature} ignoré\n" +"Le prérequis {name} a échoué ; {feature} est donc désactivé pour cette " +"exécution.\n" +"Raison : {reason}" + +#: src/iac_code/ui/repl.py +msgid "configured feature" +msgstr "fonctionnalité configurée" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Ignoring saved pipeline state: {reason}" @@ -5275,11 +5788,6 @@ msgstr "" "✎ Ajouté à {target}\n" " Vous avez dit : {user_msg}" -#: src/iac_code/ui/repl.py -#, python-brace-format -msgid "Reason: {reason}" -msgstr "Raison : {reason}" - #: src/iac_code/ui/repl.py #, python-brace-format msgid "" @@ -6028,24 +6536,6 @@ msgstr "Refus d’ouvrir un fichier non ordinaire : {path}" #~ msgid "Resume a session by ID" #~ msgstr "Reprendre une session par identifiant" -#~ msgid "sed in-place edit requires confirmation" -#~ msgstr "L'édition sed sur place nécessite une confirmation" - -#~ msgid "{n} more line{s}" -#~ msgstr "{n} ligne{s} supplémentaire{s}" - -#~ msgid "{n} message{s}" -#~ msgstr "{n} message{s}" - -#~ msgid "{n} minute{s} ago" -#~ msgstr "il y a {n} minute{s}" - -#~ msgid "{n} hour{s} ago" -#~ msgstr "il y a {n} heure{s}" - -#~ msgid "{n} day{s} ago" -#~ msgstr "il y a {n} jour{s}" - #~ msgid "Project Memory Index" #~ msgstr "Index de mémoire du projet" @@ -6256,3 +6746,9 @@ msgstr "Refus d’ouvrir un fichier non ordinaire : {path}" #~ " prises en charge en mode pipeline" #~ " et seront ignorées." +#~ msgid "review" +#~ msgstr "revue" + +#~ msgid "feature" +#~ msgstr "fonctionnalité" + diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index b2002ec8..095b97a3 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -578,8 +578,9 @@ msgstr "" " ドキュメント:https://aliyun.github.io/iac-" "code/ja/docs/configuration/authentication\n" -#: src/iac_code/cli/headless.py src/iac_code/ui/renderer.py -#: src/iac_code/ui/repl.py +#: src/iac_code/cli/headless.py +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/renderer.py src/iac_code/ui/repl.py #, python-brace-format msgid "Error: {error}" msgstr "エラー:{error}" @@ -2844,6 +2845,10 @@ msgstr "ROS コスト見積もり" msgid "ROS Deploy" msgstr "ROS デプロイ" +#: src/iac_code/pipeline/display_names.py +msgid "InfraGuard scan" +msgstr "InfraGuard スキャン" + #: src/iac_code/pipeline/engine/ask_user_question_tool.py msgid "" "Pipeline-only tool that asks the user to choose an option or type " @@ -2977,6 +2982,68 @@ msgstr "" msgid "Detected {count} rollback cleanup resources; starting cleanup." msgstr "{count} 件のロールバッククリーンアップリソースを検出しました。クリーンアップを開始します。" +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing ran write_file/edit_file after ros_validate_template; rerun " +"ros_validate_template and infraguard_scan for the same file_path." +msgstr "" +"reviewing は ros_validate_template の後に write_file/edit_file を実行しました。同じ " +"file_path に対して ros_validate_template と infraguard_scan を再実行してください。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing must validate the repaired template with ros_validate_template " +"for the same file_path." +msgstr "reviewing は修復後のテンプレートを同じ file_path で ros_validate_template により検証する必要があります。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing ran write_file/edit_file after the final InfraGuard scan; rerun" +" ros_validate_template and infraguard_scan for the same file_path." +msgstr "" +"reviewing は最終 InfraGuard スキャンの後に write_file/edit_file を実行しました。同じ " +"file_path に対して ros_validate_template と infraguard_scan を再実行してください。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing must finish with a passing InfraGuard scan for the same " +"file_path." +msgstr "reviewing は同じ file_path に対する合格した InfraGuard スキャンで終了する必要があります。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"This input still lacks a clear cloud resource, deployment target, or " +"operations constraint; clarify with the user first." +msgstr "この入力には、明確なクラウドリソース、デプロイ先、または運用上の制約がまだ不足しています。先にユーザーへ確認してください。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"The current flow only supports Alibaba Cloud deployment requests; ask the" +" user to change the target to Alibaba Cloud or confirm that it should not" +" be handled for now." +msgstr "" +"現在のフローは Alibaba Cloud のデプロイ要求のみをサポートします。ターゲットを Alibaba Cloud " +"に変更するか、今回は処理しないことをユーザーに確認してください。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"Low-confidence intent cannot be completed directly; clarify with the user" +" first." +msgstr "信頼度の低い意図は直接完了できません。先にユーザーへ確認してください。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"This input is not a deployment or cloud resource request; ask the user to" +" provide a deployment target or confirm that it should not be handled for" +" now." +msgstr "この入力はデプロイまたはクラウドリソースの要求ではありません。デプロイ先を提示するか、今回は処理しないことをユーザーに確認してください。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"A successful deployment must wait until ros_deploy returns " +"CREATE_COMPLETE." +msgstr "デプロイ成功には、ros_deploy が CREATE_COMPLETE を返すまで待つ必要があります。" + #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" "Complete the current step by calling this tool to submit the conclusion. " @@ -3065,22 +3132,63 @@ msgid "" "{fields}." msgstr "{message} complete_step.conclusion には次のいずれかのフィールドが必要です: {fields}。" +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A conclusion content hash is required before completing the current step." +msgstr "現在のステップを完了する前に、結論内容のハッシュが必要です。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} Completion guard is missing content_field or sha256_field." +msgstr "{message} 完了ガードに content_field または sha256_field がありません。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} complete_step.conclusion.{field} must be a non-empty string." +msgstr "{message} complete_step.conclusion.{field} は空でない文字列である必要があります。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} complete_step.conclusion.{field} must include the sha256 hash." +msgstr "{message} complete_step.conclusion.{field} には sha256 ハッシュを含める必要があります。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} complete_step.conclusion.{sha256_field} must equal the sha256 " +"of complete_step.conclusion.{content_field}; actual hash was {actual}." +msgstr "" +"{message} complete_step.conclusion.{sha256_field} は " +"complete_step.conclusion.{content_field} の sha256 と一致する必要があります。実際のハッシュは " +"{actual} でした。" + #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "A successful tool result is required before completing the current step." msgstr "現在のステップを完了する前に、成功したツール結果が必要です。" +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "the required tool" +msgstr "必須ツール" + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "" -"{message} complete_step.conclusion.{field} must match the {tool} result " -"value {value}." +"{message} Call {after_tool} first and wait for a successful result before" +" calling {tool}." +msgstr "{message} 先に {after_tool} を呼び出し、成功した結果を待ってから {tool} を呼び出してください。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} The {tool} result field {field} must equal {expected}; actual " +"value was {actual}." msgstr "" -"{message} complete_step.conclusion.{field} は {tool} の結果値 {value} " -"と一致する必要があります。" +"{message} {tool} の結果フィールド {field} は {expected} と等しい必要があります。実際の値は {actual}" +" でした。" #: src/iac_code/pipeline/engine/complete_step_tool.py -msgid "" -msgstr "<欠落>" +#, python-brace-format +msgid "{message} The {tool} result must include non-empty field {field}." +msgstr "{message} {tool} の結果には空でないフィールド {field} が必要です。" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -3107,8 +3215,49 @@ msgstr "" "を呼び出し、成功した結果{status_hint}{success_hint}を待ってください。" #: src/iac_code/pipeline/engine/complete_step_tool.py -msgid "the required tool" -msgstr "必須ツール" +#, python-brace-format +msgid "" +"{message} The latest matching {tool} result field {field} must equal " +"{expected}; actual value was {actual}." +msgstr "" +"{message} 最新の一致する {tool} 結果フィールド {field} は {expected} と等しい必要があります。実際の値は " +"{actual} でした。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} The latest matching {tool} result must include non-empty field " +"{field}." +msgstr "{message} 最新の一致する {tool} 結果には空でないフィールド {field} が必要です。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} Call {tool} first and wait for a successful result." +msgstr "{message} 先に {tool} を呼び出し、成功した結果を待ってください。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A write tool" +msgstr "書き込みツール" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} {tool} ran after the required validation result for the same " +"target; rerun the required validation before completing the step." +msgstr "{message} 同じ対象の必須検証結果の後に {tool} が実行されました。ステップを完了する前に必須検証を再実行してください。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} complete_step.conclusion.{field} must match the {tool} result " +"value {value}." +msgstr "" +"{message} complete_step.conclusion.{field} は {tool} の結果値 {value} " +"と一致する必要があります。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +msgstr "<欠落>" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -3169,6 +3318,110 @@ msgstr "セッションバックアップに失敗しました。" msgid "Candidate {index}" msgstr "候補 {index}" +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "command timed out after {seconds} seconds" +msgstr "コマンドが {seconds} 秒後にタイムアウトしました" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Running {command}" +msgstr "{command} を実行中" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Finished {command}" +msgstr "{command} が完了しました" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Version check failed for {name}: {reason}" +msgstr "{name} のバージョン確認に失敗しました: {reason}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Could not determine {name} version from output." +msgstr "出力から {name} のバージョンを判定できませんでした。" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "{name} version {version} is lower than required {minimum}." +msgstr "{name} のバージョン {version} は必要な {minimum} より低いです。" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "" +"No download asset configured for platform {platform}/{architecture} in " +"installer {installer}." +msgstr "" +"インストーラ {installer} にプラットフォーム {platform}/{architecture} " +"用のダウンロード資産が設定されていません。" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "No usable download URL configured for installer {installer}." +msgstr "インストーラ {installer} に利用可能なダウンロード URL が設定されていません。" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "download asset {filename} is missing sha256" +msgstr "ダウンロード資産 {filename} に sha256 がありません" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "failed to create install directory {path}: {error}" +msgstr "インストールディレクトリ {path} の作成に失敗しました: {error}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "failed to install {filename} to {path}: {error}" +msgstr "{filename} を {path} にインストールできませんでした: {error}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}" +msgstr "{filename} をダウンロードしています" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "incomplete download for {filename}: expected {expected}, got {actual}" +msgstr "{filename} のダウンロードが不完全です: 期待値 {expected}、実際 {actual}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "sha256 mismatch for {filename}: expected {expected}, got {actual}" +msgstr "{filename} の sha256 が一致しません: 期待値 {expected}、実際 {actual}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloaded {filename}" +msgstr "{filename} をダウンロードしました" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}: {percent} ({downloaded} / {total})" +msgstr "{filename} をダウンロードしています: {percent} ({downloaded} / {total})" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}: {downloaded} downloaded" +msgstr "{filename} をダウンロードしています: {downloaded} ダウンロード済み" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "download failed: {error_type}" +msgstr "ダウンロードに失敗しました: {error_type}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "exit code {returncode}" +msgstr "終了コード {returncode}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "{command} exited with {returncode}: {body}" +msgstr "{command} は {returncode} で終了しました: {body}" + #: src/iac_code/pipeline/engine/show_diagram_tool.py msgid "ECS instance" msgstr "ECS インスタンス" @@ -3261,6 +3514,138 @@ msgstr "テンプレートファイルのパスは作業ディレクトリの外 msgid "[Image input]" msgstr "[画像入力]" +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "error" +msgstr "エラー" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "passed" +msgstr "合格" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +msgid "failed" +msgstr "失敗" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +msgid "completed" +msgstr "完了" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "InfraGuard CLI does not support --no-waivers" +msgstr "InfraGuard CLI は --no-waivers をサポートしていません" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "1 finding" +msgstr "検出事項 1 件" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "{count} findings" +msgstr "検出事項 {count} 件" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "line {line}" +msgstr "{line} 行目" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Reason: {reason}" +msgstr "理由: {reason}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Recommendation: {recommendation}" +msgstr "推奨事項: {recommendation}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Snippet: {snippet}" +msgstr "スニペット: {snippet}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "blocking {count}" +msgstr "ブロック {count} 件" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Command: {command}" +msgstr "コマンド: {command}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Status: {status}" +msgstr "ステータス: {status}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "File: {file_path}" +msgstr "ファイル: {file_path}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Mode: {mode}" +msgstr "モード: {mode}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Exit code: {exit_code}" +msgstr "終了コード: {exit_code}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Ignore waivers: {value}" +msgstr "免除を無視: {value}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Blocking severities: {severities}" +msgstr "ブロック対象の重大度: {severities}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Blocking findings: {count}" +msgstr "ブロック対象の検出事項: {count}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Aspects: {aspects}" +msgstr "観点: {aspects}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Policies:" +msgstr "ポリシー:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Summary:" +msgstr "サマリー:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Severity counts: {counts}" +msgstr "重大度別件数: {counts}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Stderr: {stderr}" +msgstr "標準エラー: {stderr}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Findings:" +msgstr "検出事項:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "No findings." +msgstr "検出事項はありません。" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Run InfraGuard static scan and return structured JSON results." +msgstr "InfraGuard の静的スキャンを実行し、構造化された JSON 結果を返します。" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py msgid "CIDR block overlapped" msgstr "CIDR ブロックが重複しています" @@ -4722,6 +5107,64 @@ msgstr "いいえ、常に \"{rule}\" を拒否(このセッション)" msgid "No, always reject this tool" msgstr "いいえ、このツールは常に拒否" +#: src/iac_code/ui/repl.py +msgid "Installing prerequisites..." +msgstr "前提条件をインストールしています..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Running: {command}" +msgstr "実行中: {command}" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Done: {command}" +msgstr "完了: {command}" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Failed: {command}" +msgstr "失敗: {command}" + +#: src/iac_code/ui/repl.py +msgid "Downloading prerequisites..." +msgstr "前提条件をダウンロードしています..." + +#: src/iac_code/ui/repl.py +msgid "Initializing prerequisites..." +msgstr "前提条件を初期化しています..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Installing prerequisites with {installer}..." +msgstr "{installer} で前提条件をインストールしています..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Download: {percent} ({downloaded} / {total})" +msgstr "ダウンロード: {percent} ({downloaded} / {total})" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Download: {downloaded} downloaded" +msgstr "ダウンロード: {downloaded} ダウンロード済み" + +#: src/iac_code/ui/repl.py +msgid "review step" +msgstr "レビュー手順" + +#: src/iac_code/ui/repl.py +msgid "Direct binary download" +msgstr "直接バイナリをダウンロード" + +#: src/iac_code/ui/repl.py +msgid "Go install" +msgstr "Go ツールチェーン" + +#: src/iac_code/ui/repl.py +msgid "Homebrew" +msgstr "Homebrew パッケージマネージャー" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Approve project MCP server {server!r} from {source}? [y/N] " @@ -4894,18 +5337,10 @@ msgstr "対応が必要な追加リソース {count} 件は表示されません msgid "↺ Rollback cleanup resume: all {count} records are completed." msgstr "↺ ロールバッククリーンアップ再開: {count} 件のレコードはすべて完了しています。" -#: src/iac_code/ui/repl.py -msgid "failed" -msgstr "失敗" - #: src/iac_code/ui/repl.py msgid "in progress" msgstr "進行中" -#: src/iac_code/ui/repl.py -msgid "completed" -msgstr "完了" - #: src/iac_code/ui/repl.py msgid "skipped" msgstr "スキップ済み" @@ -4934,6 +5369,44 @@ msgstr " [{badge}] {label}" msgid "Detected rollback cleanup resources, but cleanup prompt injection failed." msgstr "ロールバッククリーンアップリソースを検出しましたが、クリーンアッププロンプトの注入に失敗しました。" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline prerequisite {name} is missing and no configured installer is " +"usable; {feature} will be skipped for this run." +msgstr "" +"パイプラインの前提条件 {name} が見つからず、使用可能な設定済みインストーラーもありません。この実行では {feature} " +"をスキップします。" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline prerequisite {name} is missing; it is required for {feature}. " +"Choose an installer or skip this feature for this run." +msgstr "" +"パイプラインの前提条件 {name} が見つかりません。{feature} " +"に必要です。インストーラーを選択するか、この実行ではこの機能をスキップしてください。" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Skip {feature} for this run" +msgstr "この実行では {feature} をスキップ" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"{feature} skipped\n" +"Prerequisite {name} failed, so {feature} is disabled for this run.\n" +"Reason: {reason}" +msgstr "" +"{feature} をスキップしました\n" +"前提条件 {name} が失敗したため、この実行では {feature} が無効になっています。\n" +"理由: {reason}" + +#: src/iac_code/ui/repl.py +msgid "configured feature" +msgstr "設定済み機能" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Ignoring saved pipeline state: {reason}" @@ -5020,11 +5493,6 @@ msgstr "" "✎ {target} に追加しました\n" " 入力内容: {user_msg}" -#: src/iac_code/ui/repl.py -#, python-brace-format -msgid "Reason: {reason}" -msgstr "理由: {reason}" - #: src/iac_code/ui/repl.py #, python-brace-format msgid "" @@ -5750,24 +6218,6 @@ msgstr "通常ファイルではないファイルを開くことを拒否しま #~ msgid "Resume a session by ID" #~ msgstr "ID でセッションを再開する" -#~ msgid "sed in-place edit requires confirmation" -#~ msgstr "sed のインプレース編集には確認が必要です" - -#~ msgid "{n} more line{s}" -#~ msgstr "あと {n} 行{s}" - -#~ msgid "{n} message{s}" -#~ msgstr "{n} 件のメッセージ{s}" - -#~ msgid "{n} minute{s} ago" -#~ msgstr "{n} 分{s}前" - -#~ msgid "{n} hour{s} ago" -#~ msgstr "{n} 時間{s}前" - -#~ msgid "{n} day{s} ago" -#~ msgstr "{n} 日{s}前" - #~ msgid "Project Memory Index" #~ msgstr "プロジェクトメモリ索引" @@ -5921,3 +6371,9 @@ msgstr "通常ファイルではないファイルを開くことを拒否しま #~ msgid "Note: images are not supported in pipeline mode and will be ignored." #~ msgstr "注: パイプラインモードでは画像はサポートされておらず、無視されます。" +#~ msgid "review" +#~ msgstr "レビュー" + +#~ msgid "feature" +#~ msgstr "機能" + diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index ac6f3867..d3b288ce 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -606,8 +606,9 @@ msgstr "" " Documentação: https://aliyun.github.io/iac-" "code/pt/docs/configuration/authentication\n" -#: src/iac_code/cli/headless.py src/iac_code/ui/renderer.py -#: src/iac_code/ui/repl.py +#: src/iac_code/cli/headless.py +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/renderer.py src/iac_code/ui/repl.py #, python-brace-format msgid "Error: {error}" msgstr "Erro: {error}" @@ -2937,6 +2938,10 @@ msgstr "Estimativa de custo ROS" msgid "ROS Deploy" msgstr "Implantação ROS" +#: src/iac_code/pipeline/display_names.py +msgid "InfraGuard scan" +msgstr "Varredura InfraGuard" + #: src/iac_code/pipeline/engine/ask_user_question_tool.py msgid "" "Pipeline-only tool that asks the user to choose an option or type " @@ -3092,6 +3097,84 @@ msgstr "" msgid "Detected {count} rollback cleanup resources; starting cleanup." msgstr "{count} recursos de limpeza de rollback detectados; iniciando limpeza." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing ran write_file/edit_file after ros_validate_template; rerun " +"ros_validate_template and infraguard_scan for the same file_path." +msgstr "" +"reviewing executou write_file/edit_file depois de ros_validate_template; " +"execute novamente ros_validate_template e infraguard_scan para o mesmo " +"file_path." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing must validate the repaired template with ros_validate_template " +"for the same file_path." +msgstr "" +"reviewing deve validar o modelo reparado com ros_validate_template para o" +" mesmo file_path." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing ran write_file/edit_file after the final InfraGuard scan; rerun" +" ros_validate_template and infraguard_scan for the same file_path." +msgstr "" +"reviewing executou write_file/edit_file depois da verificação final do " +"InfraGuard; execute novamente ros_validate_template e infraguard_scan " +"para o mesmo file_path." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing must finish with a passing InfraGuard scan for the same " +"file_path." +msgstr "" +"reviewing deve terminar com uma verificação InfraGuard aprovada para o " +"mesmo file_path." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"This input still lacks a clear cloud resource, deployment target, or " +"operations constraint; clarify with the user first." +msgstr "" +"Esta entrada ainda não tem um recurso de nuvem, destino de implantação ou" +" restrição operacional claros; esclareça primeiro com o usuário." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"The current flow only supports Alibaba Cloud deployment requests; ask the" +" user to change the target to Alibaba Cloud or confirm that it should not" +" be handled for now." +msgstr "" +"O fluxo atual oferece suporte apenas a solicitações de implantação na " +"Alibaba Cloud; peça ao usuário para alterar o destino para Alibaba Cloud " +"ou confirmar que isso não deve ser tratado por enquanto." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"Low-confidence intent cannot be completed directly; clarify with the user" +" first." +msgstr "" +"Uma intenção de baixa confiança não pode ser concluída diretamente; " +"esclareça primeiro com o usuário." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"This input is not a deployment or cloud resource request; ask the user to" +" provide a deployment target or confirm that it should not be handled for" +" now." +msgstr "" +"Esta entrada não é uma solicitação de implantação nem de recurso de " +"nuvem; peça ao usuário para fornecer um destino de implantação ou " +"confirmar que isso não deve ser tratado por enquanto." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"A successful deployment must wait until ros_deploy returns " +"CREATE_COMPLETE." +msgstr "" +"Uma implantação bem-sucedida deve aguardar até que ros_deploy retorne " +"CREATE_COMPLETE." + #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" "Complete the current step by calling this tool to submit the conclusion. " @@ -3187,24 +3270,69 @@ msgstr "" "{message} complete_step.conclusion deve incluir um destes campos: " "{fields}." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A conclusion content hash is required before completing the current step." +msgstr "" +"É necessário um hash do conteúdo da conclusão antes de concluir a etapa " +"atual." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} Completion guard is missing content_field or sha256_field." +msgstr "{message} A guarda de conclusão não tem content_field ou sha256_field." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} complete_step.conclusion.{field} must be a non-empty string." +msgstr "{message} complete_step.conclusion.{field} deve ser uma string não vazia." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} complete_step.conclusion.{field} must include the sha256 hash." +msgstr "{message} complete_step.conclusion.{field} deve incluir o hash sha256." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} complete_step.conclusion.{sha256_field} must equal the sha256 " +"of complete_step.conclusion.{content_field}; actual hash was {actual}." +msgstr "" +"{message} complete_step.conclusion.{sha256_field} deve ser igual ao " +"sha256 de complete_step.conclusion.{content_field}; o hash real foi " +"{actual}." + #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "A successful tool result is required before completing the current step." msgstr "" "É necessário um resultado de ferramenta bem-sucedido antes de concluir a " "etapa atual." +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "the required tool" +msgstr "a ferramenta obrigatória" + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "" -"{message} complete_step.conclusion.{field} must match the {tool} result " -"value {value}." +"{message} Call {after_tool} first and wait for a successful result before" +" calling {tool}." msgstr "" -"{message} complete_step.conclusion.{field} deve corresponder ao valor de " -"resultado {value} de {tool}." +"{message} Chame {after_tool} primeiro e aguarde um resultado bem-sucedido" +" antes de chamar {tool}." #: src/iac_code/pipeline/engine/complete_step_tool.py -msgid "" -msgstr "" +#, python-brace-format +msgid "" +"{message} The {tool} result field {field} must equal {expected}; actual " +"value was {actual}." +msgstr "" +"{message} O campo {field} do resultado de {tool} deve ser igual a " +"{expected}; o valor real foi {actual}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} The {tool} result must include non-empty field {field}." +msgstr "{message} O resultado de {tool} deve incluir o campo não vazio {field}." #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -3231,8 +3359,54 @@ msgstr "" "sucedido{status_hint}{success_hint}." #: src/iac_code/pipeline/engine/complete_step_tool.py -msgid "the required tool" -msgstr "a ferramenta obrigatória" +#, python-brace-format +msgid "" +"{message} The latest matching {tool} result field {field} must equal " +"{expected}; actual value was {actual}." +msgstr "" +"{message} O campo {field} do resultado correspondente mais recente de " +"{tool} deve ser igual a {expected}; o valor real foi {actual}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} The latest matching {tool} result must include non-empty field " +"{field}." +msgstr "" +"{message} O resultado correspondente mais recente de {tool} deve incluir " +"o campo não vazio {field}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} Call {tool} first and wait for a successful result." +msgstr "{message} Chame {tool} primeiro e aguarde um resultado bem-sucedido." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A write tool" +msgstr "Uma ferramenta de escrita" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} {tool} ran after the required validation result for the same " +"target; rerun the required validation before completing the step." +msgstr "" +"{message} {tool} foi executado depois do resultado de validação " +"obrigatório para o mesmo destino; execute novamente a validação " +"obrigatória antes de concluir a etapa." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} complete_step.conclusion.{field} must match the {tool} result " +"value {value}." +msgstr "" +"{message} complete_step.conclusion.{field} deve corresponder ao valor de " +"resultado {value} de {tool}." + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +msgstr "" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -3302,6 +3476,112 @@ msgstr "Falha no backup da sessão." msgid "Candidate {index}" msgstr "Candidato {index}" +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "command timed out after {seconds} seconds" +msgstr "O comando expirou após {seconds} segundos" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Running {command}" +msgstr "Executando {command}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Finished {command}" +msgstr "Concluído {command}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Version check failed for {name}: {reason}" +msgstr "A verificação de versão de {name} falhou: {reason}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Could not determine {name} version from output." +msgstr "Não foi possível determinar a versão de {name} a partir da saída." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "{name} version {version} is lower than required {minimum}." +msgstr "A versão {version} de {name} é inferior à exigida {minimum}." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "" +"No download asset configured for platform {platform}/{architecture} in " +"installer {installer}." +msgstr "" +"Nenhum ativo de download configurado para a plataforma " +"{platform}/{architecture} no instalador {installer}." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "No usable download URL configured for installer {installer}." +msgstr "" +"Nenhuma URL de download utilizável configurada para o instalador " +"{installer}." + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "download asset {filename} is missing sha256" +msgstr "o ativo de download {filename} não tem sha256" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "failed to create install directory {path}: {error}" +msgstr "falha ao criar o diretório de instalação {path}: {error}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "failed to install {filename} to {path}: {error}" +msgstr "falha ao instalar {filename} em {path}: {error}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}" +msgstr "Baixando {filename}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "incomplete download for {filename}: expected {expected}, got {actual}" +msgstr "download incompleto para {filename}: esperado {expected}, obtido {actual}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "sha256 mismatch for {filename}: expected {expected}, got {actual}" +msgstr "sha256 incompatível para {filename}: esperado {expected}, obtido {actual}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloaded {filename}" +msgstr "{filename} baixado" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}: {percent} ({downloaded} / {total})" +msgstr "Baixando {filename}: {percent} ({downloaded} / {total})" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}: {downloaded} downloaded" +msgstr "Baixando {filename}: {downloaded} baixados" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "download failed: {error_type}" +msgstr "falha no download: {error_type}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "exit code {returncode}" +msgstr "código de saída {returncode}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "{command} exited with {returncode}: {body}" +msgstr "{command} saiu com {returncode}: {body}" + #: src/iac_code/pipeline/engine/show_diagram_tool.py msgid "ECS instance" msgstr "Instância ECS" @@ -3401,6 +3681,140 @@ msgstr "O caminho do arquivo de template não pode sair do diretório de trabalh msgid "[Image input]" msgstr "[Entrada de imagem]" +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "error" +msgstr "erro" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "passed" +msgstr "aprovado" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +msgid "failed" +msgstr "falhou" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +msgid "completed" +msgstr "concluído" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "InfraGuard CLI does not support --no-waivers" +msgstr "A CLI do InfraGuard não oferece suporte a --no-waivers" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "1 finding" +msgstr "1 achado" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "{count} findings" +msgstr "{count} achados" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "line {line}" +msgstr "linha {line}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Reason: {reason}" +msgstr "Motivo: {reason}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Recommendation: {recommendation}" +msgstr "Recomendação: {recommendation}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Snippet: {snippet}" +msgstr "Trecho: {snippet}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "blocking {count}" +msgstr "{count} bloqueantes" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Command: {command}" +msgstr "Comando: {command}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Status: {status}" +msgstr "Estado: {status}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "File: {file_path}" +msgstr "Arquivo: {file_path}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Mode: {mode}" +msgstr "Modo: {mode}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Exit code: {exit_code}" +msgstr "Código de saída: {exit_code}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Ignore waivers: {value}" +msgstr "Ignorar dispensas: {value}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Blocking severities: {severities}" +msgstr "Severidades bloqueantes: {severities}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Blocking findings: {count}" +msgstr "Achados bloqueantes: {count}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Aspects: {aspects}" +msgstr "Aspectos: {aspects}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Policies:" +msgstr "Políticas:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Summary:" +msgstr "Resumo:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Severity counts: {counts}" +msgstr "Contagem por severidade: {counts}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Stderr: {stderr}" +msgstr "Erro padrão: {stderr}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Findings:" +msgstr "Achados:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "No findings." +msgstr "Nenhum achado." + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Run InfraGuard static scan and return structured JSON results." +msgstr "" +"Executa uma varredura estática do InfraGuard e retorna resultados JSON " +"estruturados." + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py msgid "CIDR block overlapped" msgstr "Bloco CIDR sobreposto" @@ -4896,6 +5310,64 @@ msgstr "Não, sempre negar \"{rule}\" (esta sessão)" msgid "No, always reject this tool" msgstr "Não, sempre rejeitar esta ferramenta" +#: src/iac_code/ui/repl.py +msgid "Installing prerequisites..." +msgstr "Instalando pré-requisitos..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Running: {command}" +msgstr "Executando: {command}" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Done: {command}" +msgstr "Concluído: {command}" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Failed: {command}" +msgstr "Falhou: {command}" + +#: src/iac_code/ui/repl.py +msgid "Downloading prerequisites..." +msgstr "Baixando pré-requisitos..." + +#: src/iac_code/ui/repl.py +msgid "Initializing prerequisites..." +msgstr "Inicializando pré-requisitos..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Installing prerequisites with {installer}..." +msgstr "Instalando pré-requisitos com {installer}..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Download: {percent} ({downloaded} / {total})" +msgstr "Download: {percent} ({downloaded} / {total})" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Download: {downloaded} downloaded" +msgstr "Download: {downloaded} baixados" + +#: src/iac_code/ui/repl.py +msgid "review step" +msgstr "etapa de revisão" + +#: src/iac_code/ui/repl.py +msgid "Direct binary download" +msgstr "Download binário direto" + +#: src/iac_code/ui/repl.py +msgid "Go install" +msgstr "Instalação com Go" + +#: src/iac_code/ui/repl.py +msgid "Homebrew" +msgstr "Homebrew (gerenciador de pacotes)" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Approve project MCP server {server!r} from {source}? [y/N] " @@ -5079,18 +5551,10 @@ msgstr "" "↺ Retomada da limpeza de rollback: todos os {count} registros foram " "concluídos." -#: src/iac_code/ui/repl.py -msgid "failed" -msgstr "falhou" - #: src/iac_code/ui/repl.py msgid "in progress" msgstr "em andamento" -#: src/iac_code/ui/repl.py -msgid "completed" -msgstr "concluído" - #: src/iac_code/ui/repl.py msgid "skipped" msgstr "ignorado" @@ -5121,6 +5585,45 @@ msgstr "" "Recursos de limpeza de rollback foram detectados, mas a injeção do prompt" " de limpeza falhou." +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline prerequisite {name} is missing and no configured installer is " +"usable; {feature} will be skipped for this run." +msgstr "" +"O pré-requisito de pipeline {name} está ausente e nenhum instalador " +"configurado pode ser usado; {feature} será ignorado nesta execução." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline prerequisite {name} is missing; it is required for {feature}. " +"Choose an installer or skip this feature for this run." +msgstr "" +"O pré-requisito de pipeline {name} está ausente; ele é necessário para " +"{feature}. Escolha um instalador ou ignore este recurso nesta execução." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Skip {feature} for this run" +msgstr "Ignorar {feature} nesta execução" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"{feature} skipped\n" +"Prerequisite {name} failed, so {feature} is disabled for this run.\n" +"Reason: {reason}" +msgstr "" +"{feature} ignorado\n" +"O pré-requisito {name} falhou, portanto {feature} está desabilitado nesta" +" execução.\n" +"Motivo: {reason}" + +#: src/iac_code/ui/repl.py +msgid "configured feature" +msgstr "recurso configurado" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Ignoring saved pipeline state: {reason}" @@ -5217,11 +5720,6 @@ msgstr "" "✎ Adicionado a {target}\n" " Você disse: {user_msg}" -#: src/iac_code/ui/repl.py -#, python-brace-format -msgid "Reason: {reason}" -msgstr "Motivo: {reason}" - #: src/iac_code/ui/repl.py #, python-brace-format msgid "" @@ -5968,24 +6466,6 @@ msgstr "Recusado abrir arquivo não regular: {path}" #~ msgid "Resume a session by ID" #~ msgstr "Retomar uma sessão pelo ID" -#~ msgid "sed in-place edit requires confirmation" -#~ msgstr "A edição sed no local requer confirmação" - -#~ msgid "{n} more line{s}" -#~ msgstr "mais {n} linha{s}" - -#~ msgid "{n} message{s}" -#~ msgstr "{n} mensagem{s}" - -#~ msgid "{n} minute{s} ago" -#~ msgstr "há {n} minuto{s}" - -#~ msgid "{n} hour{s} ago" -#~ msgstr "há {n} hora{s}" - -#~ msgid "{n} day{s} ago" -#~ msgstr "há {n} dia{s}" - #~ msgid "Project Memory Index" #~ msgstr "Índice de memória do projeto" @@ -6185,3 +6665,9 @@ msgstr "Recusado abrir arquivo não regular: {path}" #~ "Observação: imagens não são suportadas " #~ "no modo pipeline e serão ignoradas." +#~ msgid "review" +#~ msgstr "revisão" + +#~ msgid "feature" +#~ msgstr "recurso" + diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 5997448c..35654bb6 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -574,8 +574,9 @@ msgstr "" " 文档:https://aliyun.github.io/iac-code/zh-" "Hans/docs/configuration/authentication\n" -#: src/iac_code/cli/headless.py src/iac_code/ui/renderer.py -#: src/iac_code/ui/repl.py +#: src/iac_code/cli/headless.py +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/renderer.py src/iac_code/ui/repl.py #, python-brace-format msgid "Error: {error}" msgstr "错误:{error}" @@ -2820,6 +2821,10 @@ msgstr "ROS 费用估算" msgid "ROS Deploy" msgstr "ROS 部署" +#: src/iac_code/pipeline/display_names.py +msgid "InfraGuard scan" +msgstr "InfraGuard 扫描" + #: src/iac_code/pipeline/engine/ask_user_question_tool.py msgid "" "Pipeline-only tool that asks the user to choose an option or type " @@ -2947,6 +2952,66 @@ msgstr "" msgid "Detected {count} rollback cleanup resources; starting cleanup." msgstr "检测到 {count} 个回滚清理资源;开始清理。" +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing ran write_file/edit_file after ros_validate_template; rerun " +"ros_validate_template and infraguard_scan for the same file_path." +msgstr "" +"reviewing 在 ros_validate_template 之后运行了 write_file/edit_file;请对同一 " +"file_path 重新运行 ros_validate_template 和 infraguard_scan。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing must validate the repaired template with ros_validate_template " +"for the same file_path." +msgstr "reviewing 必须使用 ros_validate_template 校验修复后的模板,且 file_path 必须一致。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing ran write_file/edit_file after the final InfraGuard scan; rerun" +" ros_validate_template and infraguard_scan for the same file_path." +msgstr "" +"reviewing 在最终 InfraGuard 扫描之后运行了 write_file/edit_file;请对同一 file_path 重新运行" +" ros_validate_template 和 infraguard_scan。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"reviewing must finish with a passing InfraGuard scan for the same " +"file_path." +msgstr "reviewing 必须以同一 file_path 的通过状态 InfraGuard 扫描结束。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"This input still lacks a clear cloud resource, deployment target, or " +"operations constraint; clarify with the user first." +msgstr "该输入仍缺少明确的云资源、部署目标或运维约束;请先向用户澄清。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"The current flow only supports Alibaba Cloud deployment requests; ask the" +" user to change the target to Alibaba Cloud or confirm that it should not" +" be handled for now." +msgstr "当前流程只支持阿里云部署需求;请让用户将目标改为阿里云,或确认本次暂不处理。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"Low-confidence intent cannot be completed directly; clarify with the user" +" first." +msgstr "低置信度意图不能直接完成;请先向用户澄清。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"This input is not a deployment or cloud resource request; ask the user to" +" provide a deployment target or confirm that it should not be handled for" +" now." +msgstr "该输入不是部署或云资源需求;请让用户提供部署目标,或确认本次暂不处理。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +"A successful deployment must wait until ros_deploy returns " +"CREATE_COMPLETE." +msgstr "部署成功必须等待 ros_deploy 返回 CREATE_COMPLETE。" + #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "" "Complete the current step by calling this tool to submit the conclusion. " @@ -3033,20 +3098,60 @@ msgid "" "{fields}." msgstr "{message} complete_step.conclusion 必须包含以下字段之一: {fields}。" +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A conclusion content hash is required before completing the current step." +msgstr "完成当前步骤前需要提供结论内容哈希。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} Completion guard is missing content_field or sha256_field." +msgstr "{message} 完成守卫缺少 content_field 或 sha256_field。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} complete_step.conclusion.{field} must be a non-empty string." +msgstr "{message} complete_step.conclusion.{field} 必须是非空字符串。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} complete_step.conclusion.{field} must include the sha256 hash." +msgstr "{message} complete_step.conclusion.{field} 必须包含 sha256 哈希。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} complete_step.conclusion.{sha256_field} must equal the sha256 " +"of complete_step.conclusion.{content_field}; actual hash was {actual}." +msgstr "" +"{message} complete_step.conclusion.{sha256_field} 必须等于 " +"complete_step.conclusion.{content_field} 的 sha256;实际哈希为 {actual}。" + #: src/iac_code/pipeline/engine/complete_step_tool.py msgid "A successful tool result is required before completing the current step." msgstr "完成当前步骤前需要成功的工具结果。" +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "the required tool" +msgstr "必需工具" + #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format msgid "" -"{message} complete_step.conclusion.{field} must match the {tool} result " -"value {value}." -msgstr "{message} complete_step.conclusion.{field} 必须与 {tool} 结果值 {value} 匹配。" +"{message} Call {after_tool} first and wait for a successful result before" +" calling {tool}." +msgstr "{message} 请先调用 {after_tool} 并等待成功结果,然后再调用 {tool}。" #: src/iac_code/pipeline/engine/complete_step_tool.py -msgid "" -msgstr "<缺失>" +#, python-brace-format +msgid "" +"{message} The {tool} result field {field} must equal {expected}; actual " +"value was {actual}." +msgstr "{message} {tool} 结果字段 {field} 必须等于 {expected};实际值为 {actual}。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} The {tool} result must include non-empty field {field}." +msgstr "{message} {tool} 结果必须包含非空字段 {field}。" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -3071,8 +3176,45 @@ msgid "" msgstr "{message} 请先调用 {tool}{action},并等待成功结果{status_hint}{success_hint}。" #: src/iac_code/pipeline/engine/complete_step_tool.py -msgid "the required tool" -msgstr "必需工具" +#, python-brace-format +msgid "" +"{message} The latest matching {tool} result field {field} must equal " +"{expected}; actual value was {actual}." +msgstr "{message} 最新匹配的 {tool} 结果字段 {field} 必须等于 {expected};实际值为 {actual}。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} The latest matching {tool} result must include non-empty field " +"{field}." +msgstr "{message} 最新匹配的 {tool} 结果必须包含非空字段 {field}。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "{message} Call {tool} first and wait for a successful result." +msgstr "{message} 请先调用 {tool} 并等待成功结果。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "A write tool" +msgstr "写入工具" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} {tool} ran after the required validation result for the same " +"target; rerun the required validation before completing the step." +msgstr "{message} {tool} 在同一目标的必需验证结果之后运行;请在完成步骤前重新运行必需验证。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +#, python-brace-format +msgid "" +"{message} complete_step.conclusion.{field} must match the {tool} result " +"value {value}." +msgstr "{message} complete_step.conclusion.{field} 必须与 {tool} 结果值 {value} 匹配。" + +#: src/iac_code/pipeline/engine/complete_step_tool.py +msgid "" +msgstr "<缺失>" #: src/iac_code/pipeline/engine/complete_step_tool.py #, python-brace-format @@ -3131,6 +3273,108 @@ msgstr "会话备份失败。" msgid "Candidate {index}" msgstr "候选方案 {index}" +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "command timed out after {seconds} seconds" +msgstr "命令在 {seconds} 秒后超时" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Running {command}" +msgstr "正在运行 {command}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Finished {command}" +msgstr "已完成 {command}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Version check failed for {name}: {reason}" +msgstr "{name} 版本检查失败:{reason}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Could not determine {name} version from output." +msgstr "无法从输出中确定 {name} 版本。" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "{name} version {version} is lower than required {minimum}." +msgstr "{name} 版本 {version} 低于要求的 {minimum}。" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "" +"No download asset configured for platform {platform}/{architecture} in " +"installer {installer}." +msgstr "安装器 {installer} 没有为平台 {platform}/{architecture} 配置下载资产。" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "No usable download URL configured for installer {installer}." +msgstr "安装器 {installer} 没有可用的下载 URL。" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "download asset {filename} is missing sha256" +msgstr "下载资产 {filename} 缺少 sha256" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "failed to create install directory {path}: {error}" +msgstr "创建安装目录 {path} 失败:{error}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "failed to install {filename} to {path}: {error}" +msgstr "将 {filename} 安装到 {path} 失败:{error}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}" +msgstr "正在下载 {filename}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "incomplete download for {filename}: expected {expected}, got {actual}" +msgstr "{filename} 下载不完整:预期 {expected},实际 {actual}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "sha256 mismatch for {filename}: expected {expected}, got {actual}" +msgstr "{filename} sha256 不匹配:预期 {expected},实际 {actual}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloaded {filename}" +msgstr "已下载 {filename}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}: {percent} ({downloaded} / {total})" +msgstr "正在下载 {filename}:{percent}({downloaded} / {total})" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "Downloading {filename}: {downloaded} downloaded" +msgstr "正在下载 {filename}:已下载 {downloaded}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "download failed: {error_type}" +msgstr "下载失败:{error_type}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "exit code {returncode}" +msgstr "退出码 {returncode}" + +#: src/iac_code/pipeline/engine/prerequisites.py +#, python-brace-format +msgid "{command} exited with {returncode}: {body}" +msgstr "{command} 退出,返回码 {returncode}:{body}" + #: src/iac_code/pipeline/engine/show_diagram_tool.py msgid "ECS instance" msgstr "ECS 实例" @@ -3221,6 +3465,138 @@ msgstr "模板文件路径不能跳出工作目录" msgid "[Image input]" msgstr "[图片输入]" +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "error" +msgstr "错误" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "passed" +msgstr "通过" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +msgid "failed" +msgstr "已失败" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +msgid "completed" +msgstr "已完成" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "InfraGuard CLI does not support --no-waivers" +msgstr "InfraGuard CLI 不支持 --no-waivers" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "1 finding" +msgstr "1 个发现" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "{count} findings" +msgstr "{count} 个发现" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "line {line}" +msgstr "第 {line} 行" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Reason: {reason}" +msgstr "原因: {reason}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Recommendation: {recommendation}" +msgstr "建议:{recommendation}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Snippet: {snippet}" +msgstr "片段:{snippet}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "blocking {count}" +msgstr "阻断 {count}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Command: {command}" +msgstr "命令:{command}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Status: {status}" +msgstr "状态:{status}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "File: {file_path}" +msgstr "文件:{file_path}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Mode: {mode}" +msgstr "模式:{mode}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Exit code: {exit_code}" +msgstr "退出码:{exit_code}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Ignore waivers: {value}" +msgstr "忽略豁免:{value}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Blocking severities: {severities}" +msgstr "阻断级别:{severities}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Blocking findings: {count}" +msgstr "阻断发现:{count}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Aspects: {aspects}" +msgstr "审查方面:{aspects}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Policies:" +msgstr "策略:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Summary:" +msgstr "摘要:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Severity counts: {counts}" +msgstr "严重级别计数:{counts}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +#, python-brace-format +msgid "Stderr: {stderr}" +msgstr "标准错误:{stderr}" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Findings:" +msgstr "发现:" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "No findings." +msgstr "未发现问题。" + +#: src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +msgid "Run InfraGuard static scan and return structured JSON results." +msgstr "运行 InfraGuard 静态扫描并返回结构化 JSON 结果。" + #: src/iac_code/pipeline/selling/tools/ros_deploy_tool.py msgid "CIDR block overlapped" msgstr "CIDR block 已重叠" @@ -4664,6 +5040,64 @@ msgstr "否,始终拒绝 \"{rule}\"(本次会话)" msgid "No, always reject this tool" msgstr "否,始终拒绝此工具" +#: src/iac_code/ui/repl.py +msgid "Installing prerequisites..." +msgstr "正在安装前置依赖..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Running: {command}" +msgstr "正在运行:{command}" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Done: {command}" +msgstr "已完成:{command}" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Failed: {command}" +msgstr "失败:{command}" + +#: src/iac_code/ui/repl.py +msgid "Downloading prerequisites..." +msgstr "正在下载前置依赖..." + +#: src/iac_code/ui/repl.py +msgid "Initializing prerequisites..." +msgstr "正在初始化前置依赖..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Installing prerequisites with {installer}..." +msgstr "正在通过 {installer} 安装前置依赖..." + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Download: {percent} ({downloaded} / {total})" +msgstr "下载:{percent}({downloaded} / {total})" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Download: {downloaded} downloaded" +msgstr "下载:已下载 {downloaded}" + +#: src/iac_code/ui/repl.py +msgid "review step" +msgstr "审查步骤" + +#: src/iac_code/ui/repl.py +msgid "Direct binary download" +msgstr "直接下载二进制" + +#: src/iac_code/ui/repl.py +msgid "Go install" +msgstr "Go 工具链" + +#: src/iac_code/ui/repl.py +msgid "Homebrew" +msgstr "Homebrew 包管理器" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Approve project MCP server {server!r} from {source}? [y/N] " @@ -4836,18 +5270,10 @@ msgstr "还有 {count} 个需要注意的资源未显示。" msgid "↺ Rollback cleanup resume: all {count} records are completed." msgstr "↺ 回滚清理恢复:所有 {count} 条记录都已完成。" -#: src/iac_code/ui/repl.py -msgid "failed" -msgstr "已失败" - #: src/iac_code/ui/repl.py msgid "in progress" msgstr "进行中" -#: src/iac_code/ui/repl.py -msgid "completed" -msgstr "已完成" - #: src/iac_code/ui/repl.py msgid "skipped" msgstr "已跳过" @@ -4876,6 +5302,40 @@ msgstr " [{badge}] {label}" msgid "Detected rollback cleanup resources, but cleanup prompt injection failed." msgstr "检测到回滚清理资源,但清理提示注入失败。" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline prerequisite {name} is missing and no configured installer is " +"usable; {feature} will be skipped for this run." +msgstr "流水线前置依赖 {name} 缺失,且没有可用的安装器;本次运行将跳过 {feature}。" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline prerequisite {name} is missing; it is required for {feature}. " +"Choose an installer or skip this feature for this run." +msgstr "流水线前置依赖 {name} 缺失;它是 {feature} 所必需的。请选择安装器,或在本次运行中跳过此功能。" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Skip {feature} for this run" +msgstr "本次运行跳过 {feature}" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"{feature} skipped\n" +"Prerequisite {name} failed, so {feature} is disabled for this run.\n" +"Reason: {reason}" +msgstr "" +"{feature}已跳过\n" +"前置依赖 {name} 失败,因此本次运行已禁用 {feature}。\n" +"原因:{reason}" + +#: src/iac_code/ui/repl.py +msgid "configured feature" +msgstr "配置的功能" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Ignoring saved pipeline state: {reason}" @@ -4960,11 +5420,6 @@ msgstr "" "✎ 已补充到 {target}\n" " 你说: {user_msg}" -#: src/iac_code/ui/repl.py -#, python-brace-format -msgid "Reason: {reason}" -msgstr "原因: {reason}" - #: src/iac_code/ui/repl.py #, python-brace-format msgid "" @@ -5688,24 +6143,6 @@ msgstr "拒绝打开非常规文件:{path}" #~ msgid "Resume a session by ID" #~ msgstr "通过 ID 恢复会话" -#~ msgid "sed in-place edit requires confirmation" -#~ msgstr "sed 原地编辑需要确认" - -#~ msgid "{n} more line{s}" -#~ msgstr "还有 {n} 行" - -#~ msgid "{n} message{s}" -#~ msgstr "{n} 条消息" - -#~ msgid "{n} minute{s} ago" -#~ msgstr "{n} 分钟前" - -#~ msgid "{n} hour{s} ago" -#~ msgstr "{n} 小时前" - -#~ msgid "{n} day{s} ago" -#~ msgstr "{n} 天前" - #~ msgid "Project Memory Index" #~ msgstr "项目记忆索引" @@ -5839,3 +6276,9 @@ msgstr "拒绝打开非常规文件:{path}" #~ msgid "Note: images are not supported in pipeline mode and will be ignored." #~ msgstr "注意:pipeline 模式不支持图像,将忽略图像。" +#~ msgid "review" +#~ msgstr "审查" + +#~ msgid "feature" +#~ msgstr "功能" + diff --git a/src/iac_code/pipeline/__init__.py b/src/iac_code/pipeline/__init__.py index 29e4741c..49352e15 100644 --- a/src/iac_code/pipeline/__init__.py +++ b/src/iac_code/pipeline/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Any @@ -35,6 +36,7 @@ def create_pipeline( resume_from_sidecar: bool = False, surface: str = "repl", backup_service: Any | None = None, + prerequisite_resolution: dict[str, Any] | None = None, ) -> PipelineRunner: """Factory: create a pipeline runner by name. @@ -51,6 +53,24 @@ def create_pipeline( if name not in pipelines: available = list(pipelines.keys()) raise ValueError(f"Unknown pipeline: {name!r}. Available: {available}") + resolved_prerequisites = prerequisite_resolution + if resolved_prerequisites is None and resume_from_sidecar: + from iac_code.pipeline.engine.session import PipelineSession + + raw_session_dir = None + for method_name in ("v2_session_dir", "session_dir"): + session_dir_method = getattr(session_storage, method_name, None) + if not callable(session_dir_method): + continue + try: + candidate = session_dir_method(cwd or os.getcwd(), session_id) + except (AttributeError, TypeError): + continue + if isinstance(candidate, (str, Path)): + raw_session_dir = candidate + break + if raw_session_dir is not None: + resolved_prerequisites = PipelineSession(Path(raw_session_dir) / "pipeline").peek_prerequisites_sync() return PipelineRunner( pipeline_dir=pipelines[name], provider_manager=provider_manager, @@ -64,6 +84,7 @@ def create_pipeline( resume_from_sidecar=resume_from_sidecar, surface=surface, backup_service=backup_service, + prerequisite_resolution=resolved_prerequisites, ) diff --git a/src/iac_code/pipeline/display_names.py b/src/iac_code/pipeline/display_names.py index 01c6f5c2..1b752c3f 100644 --- a/src/iac_code/pipeline/display_names.py +++ b/src/iac_code/pipeline/display_names.py @@ -62,6 +62,7 @@ def _known_tool_names() -> dict[str, str]: "ros_preview_template": _("ROS Preview Stack"), "ros_estimate_template_cost": _("ROS Estimate Cost"), "ros_deploy": _("ROS Deploy"), + "infraguard_scan": _("InfraGuard scan"), } diff --git a/src/iac_code/pipeline/engine/complete_step_tool.py b/src/iac_code/pipeline/engine/complete_step_tool.py index e7f99752..a33b9958 100644 --- a/src/iac_code/pipeline/engine/complete_step_tool.py +++ b/src/iac_code/pipeline/engine/complete_step_tool.py @@ -2,8 +2,10 @@ from __future__ import annotations +import hashlib import json import logging +import os import re from typing import TYPE_CHECKING, Any @@ -27,6 +29,75 @@ r"session|signature|token|api[_-]?key|access[_-]?key)" ) +_COMPLETION_GUARD_MESSAGE_TEXT_BY_KEY = { + "reviewing_rerun_after_validate_template_write": ( + "reviewing ran write_file/edit_file after ros_validate_template; " + "rerun ros_validate_template and infraguard_scan for the same file_path." + ), + "reviewing_validate_template_required": ( + "reviewing must validate the repaired template with ros_validate_template for the same file_path." + ), + "reviewing_rerun_after_final_infraguard_write": ( + "reviewing ran write_file/edit_file after the final InfraGuard scan; " + "rerun ros_validate_template and infraguard_scan for the same file_path." + ), + "reviewing_final_infraguard_required": ( + "reviewing must finish with a passing InfraGuard scan for the same file_path." + ), + "intent_cloud_resource_clarification_required": ( + "This input still lacks a clear cloud resource, deployment target, or operations constraint; " + "clarify with the user first." + ), + "intent_alibaba_cloud_only": ( + "The current flow only supports Alibaba Cloud deployment requests; ask the user to change the target " + "to Alibaba Cloud or confirm that it should not be handled for now." + ), + "intent_low_confidence_clarification_required": ( + "Low-confidence intent cannot be completed directly; clarify with the user first." + ), + "intent_not_deployment_request": ( + "This input is not a deployment or cloud resource request; ask the user to provide a deployment target " + "or confirm that it should not be handled for now." + ), + "deploy_wait_create_complete": ("A successful deployment must wait until ros_deploy returns CREATE_COMPLETE."), +} +_COMPLETION_GUARD_MESSAGE_KEY_BY_TEXT = {text: key for key, text in _COMPLETION_GUARD_MESSAGE_TEXT_BY_KEY.items()} + + +def _completion_guard_message_from_key(key: str) -> str | None: + text = _COMPLETION_GUARD_MESSAGE_TEXT_BY_KEY.get(key) + return _(text) if text is not None else None + + +def _completion_guard_message_i18n_markers() -> tuple[str, ...]: + """Keep YAML-selected completion guard messages visible to Babel.""" + return ( + _( + "reviewing ran write_file/edit_file after ros_validate_template; " + "rerun ros_validate_template and infraguard_scan for the same file_path." + ), + _("reviewing must validate the repaired template with ros_validate_template for the same file_path."), + _( + "reviewing ran write_file/edit_file after the final InfraGuard scan; " + "rerun ros_validate_template and infraguard_scan for the same file_path." + ), + _("reviewing must finish with a passing InfraGuard scan for the same file_path."), + _( + "This input still lacks a clear cloud resource, deployment target, or operations constraint; " + "clarify with the user first." + ), + _( + "The current flow only supports Alibaba Cloud deployment requests; ask the user to change the target " + "to Alibaba Cloud or confirm that it should not be handled for now." + ), + _("Low-confidence intent cannot be completed directly; clarify with the user first."), + _( + "This input is not a deployment or cloud resource request; ask the user to provide a deployment target " + "or confirm that it should not be handled for now." + ), + _("A successful deployment must wait until ros_deploy returns CREATE_COMPLETE."), + ) + class CompleteStepTool(Tool): """Tool used by the step LLM to signal step completion and validate the conclusion. @@ -250,11 +321,15 @@ def _validate_completion_guards(self, conclusion: dict) -> str | None: required_tool = guard.get("require_tool") required_tool_result = guard.get("require_tool_result") + required_conclusion_sha256 = guard.get("require_conclusion_sha256") required_field = guard.get("required_conclusion_field") required_any_of = guard.get("required_conclusion_any_of") or [] successful_tools = self._completion_guard_state.get("successful_tools", set()) if required_tool and required_tool not in successful_tools: - message = guard.get("message") or _("Clarification is required before completing the current step.") + message = self._completion_guard_message( + guard, + _("Clarification is required before completing the current step."), + ) return _( "{message} Call {required_tool} first, then call complete_step after receiving the tool result." ).format( @@ -262,8 +337,9 @@ def _validate_completion_guards(self, conclusion: dict) -> str | None: required_tool=required_tool, ) if required_field and self._resolve_dotted(conclusion, required_field) in (None, "", [], {}): - message = guard.get("message") or _( - "Clarification output is required before completing the current step." + message = self._completion_guard_message( + guard, + _("Clarification output is required before completing the current step."), ) return _("{message} complete_step.conclusion must include {required_field}.").format( message=message, @@ -272,8 +348,9 @@ def _validate_completion_guards(self, conclusion: dict) -> str | None: if required_any_of and all( self._resolve_dotted(conclusion, field) in (None, "", [], {}) for field in required_any_of ): - message = guard.get("message") or _( - "Clarification output is required before completing the current step." + message = self._completion_guard_message( + guard, + _("Clarification output is required before completing the current step."), ) fields = _(" or ").join(str(field) for field in required_any_of) return _("{message} complete_step.conclusion must include one of these fields: {fields}.").format( @@ -284,10 +361,69 @@ def _validate_completion_guards(self, conclusion: dict) -> str | None: validation_error = self._validate_required_tool_result( required_tool_result, conclusion, - guard.get("message"), + self._completion_guard_message(guard, None), ) if validation_error is not None: return validation_error + if isinstance(required_conclusion_sha256, dict): + validation_error = self._validate_conclusion_sha256( + required_conclusion_sha256, + conclusion, + self._completion_guard_message(guard, None), + ) + if validation_error is not None: + return validation_error + return None + + @staticmethod + def _completion_guard_message(config: dict[str, Any], default: str | None) -> str | None: + message_key = config.get("message_key") + if isinstance(message_key, str) and message_key: + return _completion_guard_message_from_key(message_key) or message_key + message = config.get("message") + if isinstance(message, str) and message: + known_key = _COMPLETION_GUARD_MESSAGE_KEY_BY_TEXT.get(message) + if known_key is not None: + return _completion_guard_message_from_key(known_key) + return message + return default + + def _validate_conclusion_sha256( + self, + requirement: dict[str, Any], + conclusion: dict[str, Any], + message: str | None, + ) -> str | None: + content_field = str(requirement.get("content_field") or requirement.get("field") or "") + sha256_field = str(requirement.get("sha256_field") or "") + base_message = message or _("A conclusion content hash is required before completing the current step.") + if not content_field or not sha256_field: + return _("{message} Completion guard is missing content_field or sha256_field.").format( + message=base_message + ) + content = self._resolve_dotted(conclusion, content_field) + expected = self._resolve_dotted(conclusion, sha256_field) + if not isinstance(content, str) or content == "": + return _("{message} complete_step.conclusion.{field} must be a non-empty string.").format( + message=base_message, + field=content_field, + ) + if not isinstance(expected, str) or expected == "": + return _("{message} complete_step.conclusion.{field} must include the sha256 hash.").format( + message=base_message, + field=sha256_field, + ) + actual = hashlib.sha256(content.encode("utf-8")).hexdigest() + if actual != expected.strip().lower(): + return _( + "{message} complete_step.conclusion.{sha256_field} must equal the sha256 of " + "complete_step.conclusion.{content_field}; actual hash was {actual}." + ).format( + message=base_message, + sha256_field=sha256_field, + content_field=content_field, + actual=actual, + ) return None def _validate_required_tool_result( @@ -298,20 +434,52 @@ def _validate_required_tool_result( ) -> str | None: tool_name = str(requirement.get("tool") or "") actions = self._expected_actions(requirement) + expected_product = requirement.get("product") expected_success = requirement.get("is_success") status_in = {str(status) for status in requirement.get("status_in") or [] if status is not None} - match_conclusion_field = requirement.get("match_conclusion_field") - match_result_field = str(requirement.get("match_result_field") or "stack_id") + result_field_equals = requirement.get("result_field_equals") or {} + required_result_fields = requirement.get("required_result_fields") or [] base_message = message or _("A successful tool result is required before completing the current step.") records = self._completion_guard_state.get("tool_result_records") or [] + after_index = -1 + after_requirement = requirement.get("after_tool_result") + if isinstance(after_requirement, dict): + after_match_index = self._latest_satisfied_tool_result_index(records, after_requirement, conclusion) + if after_match_index is None: + after_tool = str(after_requirement.get("tool") or _("the required tool")) + return _( + "{message} Call {after_tool} first and wait for a successful result before calling {tool}." + ).format( + message=base_message, + after_tool=after_tool, + tool=tool_name or _("the required tool"), + ) + after_index = after_match_index + if bool(requirement.get("latest_match")): + return self._validate_latest_required_tool_result( + records, + requirement, + conclusion, + base_message, + after_index=after_index, + ) + mismatch_message: str | None = None - for record in records: + field_mismatch_message: str | None = None + for index, record in enumerate(records): + if index <= after_index: + continue if not isinstance(record, dict): continue if tool_name and record.get("tool_name") != tool_name: continue tool_input = record.get("input") if isinstance(record.get("input"), dict) else {} + if expected_product and not self._strings_equal_ignore_case( + self._first_string(tool_input, ("product", "Product")), + str(expected_product), + ): + continue if actions and self._first_string(tool_input, ("action", "Action")) not in actions: continue if record.get("is_error"): @@ -325,25 +493,46 @@ def _validate_required_tool_result( status = self._status_from_result(result) if status not in status_in: continue - if isinstance(match_conclusion_field, str) and match_conclusion_field: - conclusion_value = self._resolve_dotted(conclusion, match_conclusion_field) - result_value = ( - self._stack_id_from_result(result) - if match_result_field == "stack_id" - else self._resolve_dotted(result, match_result_field) - ) - if conclusion_value != result_value: - mismatch_message = _( - "{message} complete_step.conclusion.{field} must match the {tool} result value {value}." + field_mismatch = self._first_match_field_mismatch(requirement, conclusion, tool_input, result, tool_name) + if field_mismatch is not None: + mismatch_message = self._format_match_field_mismatch(base_message, field_mismatch) + continue + if isinstance(result_field_equals, dict): + failed_field = self._first_failed_result_field(result, result_field_equals) + if failed_field is not None: + field, expected, actual = failed_field + field_mismatch_message = _( + "{message} The {tool} result field {field} must equal {expected}; actual value was {actual}." ).format( message=base_message, - field=match_conclusion_field, tool=tool_name or _("tool"), - value=result_value or _(""), + field=field, + expected=expected, + actual=actual, ) continue + missing_required_field = self._first_missing_result_field(result, required_result_fields) + if missing_required_field is not None: + field_mismatch_message = _("{message} The {tool} result must include non-empty field {field}.").format( + message=base_message, + tool=tool_name or _("tool"), + field=missing_required_field, + ) + continue + disallowed_message = self._validate_no_disallowed_tool_results_after_match( + records, + requirement, + conclusion, + base_message, + matched_index=index, + ) + if disallowed_message is not None: + field_mismatch_message = disallowed_message + continue return None + if field_mismatch_message is not None: + return field_mismatch_message if mismatch_message is not None: return mismatch_message status_hint = "" @@ -367,6 +556,333 @@ def _validate_required_tool_result( success_hint=success_hint, ) + def _latest_satisfied_tool_result_index( + self, + records: list[Any], + requirement: dict[str, Any], + conclusion: dict[str, Any], + ) -> int | None: + for index in range(len(records) - 1, -1, -1): + record = records[index] + if isinstance(record, dict) and self._tool_result_record_satisfies(record, requirement, conclusion): + return index + return None + + def _tool_result_record_satisfies( + self, + record: dict[str, Any], + requirement: dict[str, Any], + conclusion: dict[str, Any], + ) -> bool: + tool_name = str(requirement.get("tool") or "") + actions = self._expected_actions(requirement) + expected_product = requirement.get("product") + expected_success = requirement.get("is_success") + status_in = {str(status) for status in requirement.get("status_in") or [] if status is not None} + result_field_equals = requirement.get("result_field_equals") or {} + required_result_fields = requirement.get("required_result_fields") or [] + + if tool_name and record.get("tool_name") != tool_name: + return False + tool_input = self._dict_value(record.get("input")) + if expected_product and not self._strings_equal_ignore_case( + self._first_string(tool_input, ("product", "Product")), + str(expected_product), + ): + return False + if actions and self._first_string(tool_input, ("action", "Action")) not in actions: + return False + if record.get("is_error"): + return False + result = record.get("result") + if not isinstance(result, dict): + return False + if expected_success is not None and self._bool_from_result(result) is not bool(expected_success): + return False + if status_in: + status = self._status_from_result(result) + if status not in status_in: + return False + if self._first_match_field_mismatch(requirement, conclusion, tool_input, result, tool_name) is not None: + return False + if isinstance(result_field_equals, dict) and self._first_failed_result_field(result, result_field_equals): + return False + if self._first_missing_result_field(result, required_result_fields) is not None: + return False + return True + + def _validate_latest_required_tool_result( + self, + records: list[Any], + requirement: dict[str, Any], + conclusion: dict[str, Any], + base_message: str, + *, + after_index: int, + ) -> str | None: + tool_name = str(requirement.get("tool") or "") + actions = self._expected_actions(requirement) + expected_product = requirement.get("product") + expected_success = requirement.get("is_success") + status_in = {str(status) for status in requirement.get("status_in") or [] if status is not None} + result_field_equals = requirement.get("result_field_equals") or {} + required_result_fields = requirement.get("required_result_fields") or [] + mismatch_message: str | None = None + + for index in range(len(records) - 1, after_index, -1): + record = records[index] + if not isinstance(record, dict): + continue + if tool_name and record.get("tool_name") != tool_name: + continue + tool_input = record.get("input") if isinstance(record.get("input"), dict) else {} + if expected_product and not self._strings_equal_ignore_case( + self._first_string(tool_input, ("product", "Product")), + str(expected_product), + ): + continue + if actions and self._first_string(tool_input, ("action", "Action")) not in actions: + continue + raw_result = record.get("result") + result = raw_result if isinstance(raw_result, dict) else {} + field_mismatch = self._first_match_field_mismatch(requirement, conclusion, tool_input, result, tool_name) + if field_mismatch is not None: + mismatch_message = self._format_match_field_mismatch(base_message, field_mismatch) + continue + if record.get("is_error") or not isinstance(raw_result, dict): + break + if expected_success is not None and self._bool_from_result(result) is not bool(expected_success): + break + if status_in: + status = self._status_from_result(result) + if status not in status_in: + break + if isinstance(result_field_equals, dict): + failed_field = self._first_failed_result_field(result, result_field_equals) + if failed_field is not None: + field, expected, actual = failed_field + return _( + "{message} The latest matching {tool} result field {field} must equal {expected}; " + "actual value was {actual}." + ).format( + message=base_message, + tool=tool_name or _("tool"), + field=field, + expected=expected, + actual=actual, + ) + missing_required_field = self._first_missing_result_field(result, required_result_fields) + if missing_required_field is not None: + return _("{message} The latest matching {tool} result must include non-empty field {field}.").format( + message=base_message, + tool=tool_name or _("tool"), + field=missing_required_field, + ) + disallowed_message = self._validate_no_disallowed_tool_results_after_match( + records, + requirement, + conclusion, + base_message, + matched_index=index, + ) + if disallowed_message is not None: + return disallowed_message + return None + + if mismatch_message is not None: + return mismatch_message + return _("{message} Call {tool} first and wait for a successful result.").format( + message=base_message, + tool=tool_name or _("the required tool"), + ) + + def _validate_no_disallowed_tool_results_after_match( + self, + records: list[Any], + requirement: dict[str, Any], + conclusion: dict[str, Any], + base_message: str, + *, + matched_index: int, + ) -> str | None: + raw_rules = requirement.get("disallow_tool_results_after_match") or [] + if isinstance(raw_rules, dict): + rules = [raw_rules] + elif isinstance(raw_rules, list): + rules = [rule for rule in raw_rules if isinstance(rule, dict)] + else: + rules = [] + if not rules: + return None + + for record in records[matched_index + 1 :]: + if not isinstance(record, dict) or record.get("is_error"): + continue + record_tool_name = str(record.get("tool_name") or "") + tool_input = record.get("input") if isinstance(record.get("input"), dict) else {} + result = record.get("result") if isinstance(record.get("result"), dict) else {} + for rule in rules: + tool_names = self._string_set(rule.get("tool")) | self._string_set(rule.get("tools")) + if tool_names and record_tool_name not in tool_names: + continue + match_conclusion_field = rule.get("match_conclusion_field") + match_result_field = str(rule.get("match_result_field") or "file_path") + if isinstance(match_conclusion_field, str) and match_conclusion_field: + conclusion_value = self._resolve_dotted(conclusion, match_conclusion_field) + result_value = self._resolve_match_result_field(tool_input, result, match_result_field) + if not self._field_values_match( + conclusion_value, + result_value, + conclusion_field=match_conclusion_field, + result_field=match_result_field, + ): + continue + rule_message = self._completion_guard_message(rule, None) + if rule_message is not None: + return rule_message.format(message=base_message, tool=record_tool_name or _("A write tool")) + return _( + "{message} {tool} ran after the required validation result for the same target; " + "rerun the required validation before completing the step." + ).format(message=base_message, tool=record_tool_name or _("A write tool")) + return None + + @classmethod + def _resolve_match_result_field( + cls, + tool_input: dict[str, Any], + result: dict[str, Any], + match_result_field: str, + ) -> Any: + if match_result_field == "stack_id": + return cls._stack_id_from_result(result) + source = {"input": tool_input, "result": result} + value = cls._resolve_dotted(source, match_result_field) + if value is not None: + return value + return cls._resolve_dotted(result, match_result_field) + + def _first_match_field_mismatch( + self, + requirement: dict[str, Any], + conclusion: dict[str, Any], + tool_input: dict[str, Any], + result: dict[str, Any], + tool_name: str, + ) -> dict[str, Any] | None: + for field_spec in self._match_field_specs(requirement): + conclusion_field = field_spec["conclusion_field"] + result_field = field_spec["result_field"] + conclusion_value = self._resolve_dotted(conclusion, conclusion_field) + result_value = self._resolve_match_result_field(tool_input, result, result_field) + if self._field_values_match( + conclusion_value, + result_value, + conclusion_field=conclusion_field, + result_field=result_field, + ): + continue + return { + "conclusion_field": conclusion_field, + "result_field": result_field, + "result_value": result_value, + "tool_name": tool_name, + } + return None + + @staticmethod + def _match_field_specs(requirement: dict[str, Any]) -> list[dict[str, str]]: + specs: list[dict[str, str]] = [] + match_conclusion_field = requirement.get("match_conclusion_field") + if isinstance(match_conclusion_field, str) and match_conclusion_field: + specs.append( + { + "conclusion_field": match_conclusion_field, + "result_field": str(requirement.get("match_result_field") or "stack_id"), + } + ) + raw_specs = requirement.get("match_fields") or [] + if isinstance(raw_specs, dict): + raw_specs = [raw_specs] + if isinstance(raw_specs, list): + for raw_spec in raw_specs: + if not isinstance(raw_spec, dict): + continue + conclusion_field = raw_spec.get("conclusion_field") or raw_spec.get("match_conclusion_field") + result_field = raw_spec.get("result_field") or raw_spec.get("match_result_field") + if isinstance(conclusion_field, str) and conclusion_field: + specs.append( + { + "conclusion_field": conclusion_field, + "result_field": str(result_field or "stack_id"), + } + ) + return specs + + @staticmethod + def _format_match_field_mismatch(base_message: str, mismatch: dict[str, Any]) -> str: + return _("{message} complete_step.conclusion.{field} must match the {tool} result value {value}.").format( + message=base_message, + field=mismatch["conclusion_field"], + tool=mismatch["tool_name"] or _("tool"), + value=mismatch["result_value"] or _(""), + ) + + def _field_values_match( + self, + conclusion_value: Any, + result_value: Any, + *, + conclusion_field: str, + result_field: str, + ) -> bool: + if conclusion_value == result_value: + return True + if not isinstance(conclusion_value, str) or not isinstance(result_value, str): + return False + if not self._path_field(conclusion_field) and not self._path_field(result_field): + return False + cwd = self._completion_guard_state.get("cwd") + if not isinstance(cwd, str) or not cwd: + return False + return self._canonical_local_path(conclusion_value, cwd) == self._canonical_local_path(result_value, cwd) + + @staticmethod + def _path_field(field: str) -> bool: + normalized = field.lower() + return "path" in normalized or "templateurl" in normalized + + @staticmethod + def _canonical_local_path(value: str, cwd: str) -> str: + if "://" in value: + return value + expanded = os.path.expandvars(os.path.expanduser(value)) + if not os.path.isabs(expanded): + expanded = os.path.join(cwd, expanded) + return os.path.normcase(os.path.realpath(os.path.abspath(expanded))) + + @classmethod + def _first_failed_result_field( + cls, + result: dict[str, Any], + result_field_equals: dict[str, Any], + ) -> tuple[str, Any, Any] | None: + for field, expected in result_field_equals.items(): + actual = cls._resolve_dotted(result, field) + if actual != expected: + return field, expected, actual + return None + + @classmethod + def _first_missing_result_field(cls, result: dict[str, Any], required_fields: Any) -> str | None: + if not isinstance(required_fields, list): + return None + for field in required_fields: + if not isinstance(field, str) or not field: + continue + if cls._resolve_dotted(result, field) in (None, "", [], {}): + return field + return None + def validate_completion_input(self, tool_input: dict[str, Any]) -> str | None: """Validate a complete_step input without mutating retry counters.""" @@ -397,11 +913,47 @@ def _guard_applies(self, guard: dict, conclusion: dict) -> bool: return False user_patterns = guard.get("when_user_message_matches_any") or [] - if user_patterns and any(self._matches(pattern, self._user_message) for pattern in user_patterns): - return True - conclusion_equals = guard.get("when_conclusion_field_equals") or {} - return any(self._resolve_dotted(conclusion, field) == value for field, value in conclusion_equals.items()) + applies = bool(user_patterns and any(self._matches(pattern, self._user_message) for pattern in user_patterns)) + applies = applies or any( + self._resolve_dotted(conclusion, field) == value for field, value in conclusion_equals.items() + ) + if not applies: + return False + + when_tool_result_exists = guard.get("when_tool_result_exists") + if isinstance(when_tool_result_exists, dict) and not self._matching_tool_result_exists( + when_tool_result_exists, + conclusion, + ): + return False + return True + + def _matching_tool_result_exists(self, requirement: dict[str, Any], conclusion: dict[str, Any]) -> bool: + tool_names = self._string_set(requirement.get("tool")) | self._string_set(requirement.get("tools")) + match_conclusion_field = requirement.get("match_conclusion_field") + match_result_field = str(requirement.get("match_result_field") or "file_path") + records = self._completion_guard_state.get("tool_result_records") or [] + for record in records: + if not isinstance(record, dict) or record.get("is_error"): + continue + record_tool_name = str(record.get("tool_name") or "") + if tool_names and record_tool_name not in tool_names: + continue + if isinstance(match_conclusion_field, str) and match_conclusion_field: + tool_input = record.get("input") if isinstance(record.get("input"), dict) else {} + result = record.get("result") if isinstance(record.get("result"), dict) else {} + conclusion_value = self._resolve_dotted(conclusion, match_conclusion_field) + result_value = self._resolve_match_result_field(tool_input, result, match_result_field) + if not self._field_values_match( + conclusion_value, + result_value, + conclusion_field=match_conclusion_field, + result_field=match_result_field, + ): + continue + return True + return False @staticmethod def _validate_candidate_limit(conclusion: dict) -> str | None: @@ -483,6 +1035,12 @@ def _first_string(source: dict[str, Any], keys: tuple[str, ...]) -> str | None: return value return None + @staticmethod + def _strings_equal_ignore_case(left: str | None, right: str | None) -> bool: + if left is None or right is None: + return left == right + return left.lower() == right.lower() + @classmethod def _expected_actions(cls, requirement: dict[str, Any]) -> set[str]: actions: set[str] = set() diff --git a/src/iac_code/pipeline/engine/completion_guard_state.py b/src/iac_code/pipeline/engine/completion_guard_state.py index b0c86fc6..958b3f5c 100644 --- a/src/iac_code/pipeline/engine/completion_guard_state.py +++ b/src/iac_code/pipeline/engine/completion_guard_state.py @@ -4,10 +4,14 @@ import json import logging +import os from typing import Any logger = logging.getLogger(__name__) +_FILE_MUTATION_TOOLS = {"write_file", "edit_file"} +_STRUCTURED_RESULT_TOOLS = {"infraguard_scan", "ros_deploy", "ros_stack", "ros_validate_template"} + def ensure_completion_guard_state(state: dict[str, Any]) -> dict[str, Any]: state.setdefault("successful_tools", set()) @@ -23,16 +27,38 @@ def record_completion_guard_tool_result( tool_input: dict[str, Any], content: Any, is_error: bool, + cwd: str | None = None, ) -> None: """Record tool results that completion guards may need later in the same step.""" try: ensure_completion_guard_state(state) + if cwd: + state["cwd"] = cwd if tool_name == "ask_user_question": _record_ask_user_question(state, content, is_error=is_error) return - if tool_name in {"ros_stack", "ros_deploy"}: - _record_stack_tool(state, tool_name, tool_input, content, is_error=is_error) + parsed = _json_object(content, log_failure=tool_name in _STRUCTURED_RESULT_TOOLS) + if parsed is None and tool_name in _FILE_MUTATION_TOOLS: + parsed = _file_mutation_result(tool_input, cwd=cwd) + elif parsed is not None: + _add_canonical_file_path(parsed, tool_input, cwd=cwd) + if parsed is None: + return + records: list[dict[str, Any]] = state.setdefault("tool_result_records", []) + records.append( + { + "tool_name": tool_name, + "input": dict(tool_input), + "result": parsed, + "is_error": bool(is_error), + } + ) + state.setdefault("tool_results", {})[tool_name] = parsed + if tool_name == "ros_deploy": + _record_ros_deploy_owned_stack(state, tool_input, parsed) + if not is_error: + state.setdefault("successful_tools", set()).add(tool_name) except Exception: logger.warning("Failed to rebuild completion guard state", exc_info=True) @@ -53,27 +79,6 @@ def _record_ask_user_question(state: dict[str, Any], content: Any, *, is_error: tool_results["ask_user_question"] = parsed -def _record_stack_tool( - state: dict[str, Any], tool_name: str, tool_input: dict[str, Any], content: Any, *, is_error: bool -) -> None: - parsed = _json_object(content) - if parsed is None: - return - records: list[dict[str, Any]] = state.setdefault("tool_result_records", []) - record = { - "tool_name": tool_name, - "input": dict(tool_input), - "result": parsed, - "is_error": bool(is_error), - } - records.append(record) - state.setdefault("tool_results", {})[tool_name] = parsed - if tool_name == "ros_deploy": - _record_ros_deploy_owned_stack(state, tool_input, parsed) - if not is_error: - state.setdefault("successful_tools", set()).add(tool_name) - - def _record_ros_deploy_owned_stack(state: dict[str, Any], tool_input: dict[str, Any], result: dict[str, Any]) -> None: action = tool_input.get("action") if action not in {"create", "delete_and_create"}: @@ -86,7 +91,31 @@ def _record_ros_deploy_owned_stack(state: dict[str, Any], tool_input: dict[str, owned[stack_id] = {"action": action} -def _json_object(value: Any) -> dict[str, Any] | None: +def _file_mutation_result(tool_input: dict[str, Any], *, cwd: str | None = None) -> dict[str, Any] | None: + path = tool_input.get("path") or tool_input.get("file_path") + if not isinstance(path, str) or not path: + return None + result = {"file_path": path} + _add_canonical_file_path(result, tool_input, cwd=cwd) + return result + + +def _add_canonical_file_path( + result: dict[str, Any], + tool_input: dict[str, Any], + *, + cwd: str | None = None, +) -> None: + path = result.get("file_path") or tool_input.get("path") or tool_input.get("file_path") + if not isinstance(path, str) or not path or "://" in path or not cwd: + return + expanded = os.path.expandvars(os.path.expanduser(path)) + if not os.path.isabs(expanded): + expanded = os.path.join(cwd, expanded) + result.setdefault("canonical_file_path", os.path.normcase(os.path.realpath(os.path.abspath(expanded)))) + + +def _json_object(value: Any, *, log_failure: bool = True) -> dict[str, Any] | None: if isinstance(value, dict): return value if not isinstance(value, str) or not value: @@ -94,6 +123,7 @@ def _json_object(value: Any) -> dict[str, Any] | None: try: parsed = json.loads(value) except json.JSONDecodeError: - logger.warning("Failed to parse completion guard state", exc_info=True) + if log_failure: + logger.warning("Failed to parse completion guard state", exc_info=True) return None return parsed if isinstance(parsed, dict) else None diff --git a/src/iac_code/pipeline/engine/loader.py b/src/iac_code/pipeline/engine/loader.py index c1cc59c1..6c02a525 100644 --- a/src/iac_code/pipeline/engine/loader.py +++ b/src/iac_code/pipeline/engine/loader.py @@ -5,6 +5,7 @@ import importlib.util import logging import os +from dataclasses import replace from pathlib import Path from types import ModuleType from typing import Any, cast @@ -37,7 +38,12 @@ _SUPPORTED_INTERRUPT_JUDGE_FAILURE_POLICIES = {"continue", "pause", "hard_interrupt"} -def load_pipeline_dir(pipeline_dir: Path) -> LoadedPipeline: +def load_pipeline_dir( + pipeline_dir: Path, + *, + feature_flag_overrides: dict[str, bool] | None = None, + prerequisite_resolution: dict[str, Any] | None = None, +) -> LoadedPipeline: """Load a complete pipeline from a directory containing pipeline.yaml.""" yaml_path = pipeline_dir / "pipeline.yaml" raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) @@ -57,12 +63,15 @@ def load_pipeline_dir(pipeline_dir: Path) -> LoadedPipeline: base_prompt_sections = IncludeExcludeConfig(include=list(DEFAULT_PIPELINE_SECTIONS)) feature_flags = _resolve_feature_flags(raw.get("feature_flags")) + if feature_flag_overrides: + feature_flags.update(feature_flag_overrides) on_complete = _parse_on_complete(raw.get("on_complete")) sub_pipelines = _parse_sub_pipelines(raw.get("sub_pipelines", {}), feature_flags, pipeline_dir) steps = _parse_steps(raw["steps"]) steps = _filter_and_relink(steps, feature_flags) + _resolve_artifact_roles_after_filtering(steps) _bind_hooks(steps, pipeline_dir) _validate_prompts_exist(steps, pipeline_dir) @@ -93,9 +102,22 @@ def load_pipeline_dir(pipeline_dir: Path) -> LoadedPipeline: on_complete=on_complete, skill_roots=skill_roots, emit_stack_events=bool(raw.get("emit_stack_events", False)), + prerequisites=_parse_mapping(raw.get("prerequisites"), "prerequisites"), + prerequisite_resolution={} if prerequisite_resolution is None else prerequisite_resolution, ) +def _parse_mapping(raw: object, field_name: str, step_id: str | None = None) -> dict[str, Any]: + if raw is None: + return {} + label = f"Step '{step_id}': {field_name}" if step_id is not None else field_name + if not isinstance(raw, dict): + raise ValueError(f"{label} must be a mapping, got {raw!r}") + if not all(isinstance(key, str) for key in raw): + raise ValueError(f"{label} keys must be strings, got {raw!r}") + return cast(dict[str, Any], raw) + + def _resolve_feature_flags(raw_flags: dict | None) -> dict[str, bool]: """Resolve feature flags from YAML defaults + environment variable overrides.""" if not raw_flags: @@ -181,6 +203,7 @@ def _parse_sub_pipelines( for sub_name, sub_raw in raw_subs.items(): steps = _parse_steps(sub_raw.get("steps", [])) steps = _filter_and_relink(steps, feature_flags) + _resolve_artifact_roles_after_filtering(steps) _validate_prompts_exist(steps, pipeline_dir) result[sub_name] = SubPipelineSpec( name=sub_name, @@ -243,6 +266,7 @@ def _parse_steps(raw_steps: list[dict]) -> list[StepSpec]: exit_condition=_parse_exit_condition(raw.get("exit_condition"), step_id), a2a_artifacts=_parse_a2a_artifacts(raw.get("a2a_artifacts"), step_id), surface_overrides=_parse_surface_overrides(raw.get("surface_overrides"), step_id), + config=_parse_mapping(raw.get("config"), "config", step_id), ) ) return steps @@ -303,13 +327,29 @@ def _parse_a2a_artifacts(raw: object, step_id: str) -> list[A2AArtifactSpec]: path = item.get("path") or item.get("source") content = item.get("content") media_type = item.get("media_type") or item.get("mediaType") or "auto" + role = item.get("role", "final") + supersedes_path = item.get("supersedes_path") + if supersedes_path is None: + supersedes_path = item.get("supersedesPath") if not isinstance(path, str) or not path: raise ValueError(f"Step '{step_id}': a2a_artifacts[{index}].path must be a non-empty string") if not isinstance(content, str) or not content: raise ValueError(f"Step '{step_id}': a2a_artifacts[{index}].content must be a non-empty string") if not isinstance(media_type, str) or not media_type: raise ValueError(f"Step '{step_id}': a2a_artifacts[{index}].media_type must be a non-empty string") - specs.append(A2AArtifactSpec(path=path, content=content, media_type=media_type)) + if role not in {"intermediate", "final"}: + raise ValueError(f"Step '{step_id}': a2a_artifacts[{index}].role must be one of: final, intermediate") + if supersedes_path is not None and (not isinstance(supersedes_path, str) or not supersedes_path): + raise ValueError(f"Step '{step_id}': a2a_artifacts[{index}].supersedes_path must be a non-empty string") + specs.append( + A2AArtifactSpec( + path=path, + content=content, + media_type=media_type, + role=cast(str, role), + supersedes_path=cast(str | None, supersedes_path), + ) + ) return specs @@ -340,6 +380,45 @@ def _filter_and_relink(steps: list[StepSpec], feature_flags: dict[str, bool]) -> return enabled +def _resolve_artifact_roles_after_filtering(steps: list[StepSpec]) -> None: + parents: dict[str, str] = {} + + def find(key: str) -> str: + parent = parents.setdefault(key, key) + if parent != key: + parents[key] = find(parent) + return parents[key] + + def union(left: str, right: str) -> None: + left_root = find(left) + right_root = find(right) + if left_root != right_root: + parents[right_root] = left_root + + for step in steps: + for artifact in step.a2a_artifacts: + find(artifact.path) + if artifact.supersedes_path is not None: + union(artifact.supersedes_path, artifact.path) + + artifacts_by_replacement_key: dict[str, list[tuple[int, int]]] = {} + for step_index, step in enumerate(steps): + for artifact_index, artifact in enumerate(step.a2a_artifacts): + replacement_key = find(artifact.path) + artifacts_by_replacement_key.setdefault(replacement_key, []).append((step_index, artifact_index)) + + for artifacts in artifacts_by_replacement_key.values(): + if len(artifacts) < 2: + continue + latest = artifacts[-1] + for step_index, artifact_index in artifacts: + artifact = steps[step_index].a2a_artifacts[artifact_index] + role = "final" if (step_index, artifact_index) == latest else "intermediate" + if artifact.role == role: + continue + steps[step_index].a2a_artifacts[artifact_index] = replace(artifact, role=role) + + def _is_enabled(step: StepSpec, flags: dict[str, bool]) -> bool: if step.enabled_when is None: return True diff --git a/src/iac_code/pipeline/engine/pipeline_runner.py b/src/iac_code/pipeline/engine/pipeline_runner.py index cd04a2ef..677dd998 100644 --- a/src/iac_code/pipeline/engine/pipeline_runner.py +++ b/src/iac_code/pipeline/engine/pipeline_runner.py @@ -11,7 +11,7 @@ import stat import time from collections.abc import AsyncGenerator, Awaitable, Callable -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Any, cast from unittest.mock import Mock @@ -385,6 +385,7 @@ class RestartInfo: start_from_step: str | None preserved_conclusions: dict[str, Any] + preserved_step_conclusions: dict[str, Any] = field(default_factory=dict) rollback_context: str | None = None rollback_input: PipelineInputContent | None = None @@ -421,6 +422,7 @@ def __init__( resume_from_sidecar: bool = False, surface: str = "repl", backup_service: Any | None = None, + prerequisite_resolution: dict[str, Any] | None = None, ) -> None: self._session_storage = session_storage self._session_id = session_id @@ -432,7 +434,40 @@ def __init__( self._surface = surface self._pipeline_dir = pipeline_dir - self._loaded: LoadedPipeline = load_pipeline_dir(pipeline_dir) + + # Sidecar lives at ///pipeline/ — nested + # under the session directory (main's directory-format session layout) + # rather than the legacy sibling .pipeline/ that pre-dated + # the directory format. SessionStorage.session_dir is the canonical + # accessor for ///. + v2_session_dir = _storage_method(session_storage, "v2_session_dir") + session_dir = _storage_method(session_storage, "session_dir") + raw_session_dir = None + ensure_v2_session_dir = _storage_method(session_storage, "ensure_v2_session_dir_for_new_session") + if callable(ensure_v2_session_dir): + raw_session_dir = ensure_v2_session_dir(self._cwd, session_id) + if raw_session_dir is None: + if callable(v2_session_dir): + raw_session_dir = v2_session_dir(self._cwd, session_id) + if raw_session_dir is None and callable(session_dir): + raw_session_dir = self._writable_pipeline_sidecar_session_dir(session_dir(self._cwd, session_id)) + elif callable(session_dir): + raw_session_dir = session_dir(self._cwd, session_id) + self.session = ( + PipelineSession(Path(raw_session_dir) / "pipeline") if isinstance(raw_session_dir, (str, Path)) else None + ) + + resolved_prerequisites = prerequisite_resolution + if resolved_prerequisites is None and resume_from_sidecar and self.session is not None: + resolved_prerequisites = self.session.peek_prerequisites_sync() + self._prerequisite_resolution = dict(resolved_prerequisites) if isinstance(resolved_prerequisites, dict) else {} + feature_flag_overrides = self._feature_flag_overrides_from_prerequisites(self._prerequisite_resolution) + self._tool_context_env_overrides = self._env_overrides_from_prerequisites(self._prerequisite_resolution) + self._loaded: LoadedPipeline = load_pipeline_dir( + pipeline_dir, + feature_flag_overrides=feature_flag_overrides, + prerequisite_resolution=self._prerequisite_resolution, + ) self._pipeline_identity = self._build_pipeline_identity(pipeline_dir) self._observability = PipelineObservability( pipeline_name=self._loaded.name, @@ -458,28 +493,6 @@ def __init__( self._agent_pause_event = asyncio.Event() self._agent_pause_event.set() - # Sidecar lives at ///pipeline/ — nested - # under the session directory (main's directory-format session layout) - # rather than the legacy sibling .pipeline/ that pre-dated - # the directory format. SessionStorage.session_dir is the canonical - # accessor for ///. - v2_session_dir = _storage_method(session_storage, "v2_session_dir") - session_dir = _storage_method(session_storage, "session_dir") - raw_session_dir = None - ensure_v2_session_dir = _storage_method(session_storage, "ensure_v2_session_dir_for_new_session") - if callable(ensure_v2_session_dir): - raw_session_dir = ensure_v2_session_dir(self._cwd, session_id) - if raw_session_dir is None: - if callable(v2_session_dir): - raw_session_dir = v2_session_dir(self._cwd, session_id) - if raw_session_dir is None and callable(session_dir): - raw_session_dir = self._writable_pipeline_sidecar_session_dir(session_dir(self._cwd, session_id)) - elif callable(session_dir): - raw_session_dir = session_dir(self._cwd, session_id) - self.session = ( - PipelineSession(Path(raw_session_dir) / "pipeline") if isinstance(raw_session_dir, (str, Path)) else None - ) - self._attempts: dict[str, Any] = {"next_attempt_number": 1, "items": {}} self._execution: dict[str, Any] = {} self._transcript_storage = None @@ -502,6 +515,7 @@ def __init__( memory_content_getter=self._memory_content_getter, auto_trigger_skills=self._auto_trigger_skills, surface=self._surface, + tool_context_env_overrides=self._tool_context_env_overrides, ) self._apply_telemetry_correlation(self._step_executor) @@ -762,6 +776,31 @@ def _cleanup_resources_from_hook_result(result: object) -> list[CleanupResource] return [item for item in result if isinstance(item, CleanupResource)] return [] + @staticmethod + def _feature_flag_overrides_from_prerequisites( + prerequisite_resolution: dict[str, Any], + ) -> dict[str, bool] | None: + feature_flags = prerequisite_resolution.get("feature_flags") + if not isinstance(feature_flags, dict): + return None + overrides = { + name: enabled + for name, enabled in feature_flags.items() + if isinstance(name, str) and isinstance(enabled, bool) + } + return overrides or None + + @staticmethod + def _env_overrides_from_prerequisites(prerequisite_resolution: dict[str, Any]) -> dict[str, str]: + raw_overrides = prerequisite_resolution.get("env_overrides") + if not isinstance(raw_overrides, dict): + return {} + return {str(key): str(value) for key, value in raw_overrides.items() if value is not None} + + def _sidecar_prerequisites_metadata(self) -> dict[str, Any]: + raw_resolution = getattr(self, "_prerequisite_resolution", {}) + return dict(raw_resolution) if isinstance(raw_resolution, dict) else {} + def _build_pipeline_identity(self, pipeline_dir: Path) -> PipelineIdentity: yaml_path = pipeline_dir / "pipeline.yaml" digest = hashlib.sha256(yaml_path.read_bytes()).hexdigest() @@ -845,6 +884,7 @@ def mark_normal_handoff(self, status: str, failed_reason: str | None = None) -> metadata_kwargs: dict[str, Any] = { "attempts": dict(self._attempts), "normal_handoff": normal_handoff, + "prerequisites": self._sidecar_prerequisites_metadata(), } if self._execution: metadata_kwargs["execution"] = dict(self._execution) @@ -1189,10 +1229,14 @@ def _persisted_candidate_restart_info(state: dict[str, Any]) -> RestartInfo | No preserved_conclusions = restart.get("preserved_conclusions") if not isinstance(preserved_conclusions, dict): preserved_conclusions = {} + preserved_step_conclusions = restart.get("preserved_step_conclusions") + if not isinstance(preserved_step_conclusions, dict): + preserved_step_conclusions = {} rollback_input = _deserialize_pipeline_input_content(restart.get("rollback_input")) return RestartInfo( start_from_step=start_from_step, preserved_conclusions=preserved_conclusions, + preserved_step_conclusions=preserved_step_conclusions, rollback_context=rollback_context, rollback_input=rollback_input, ) @@ -1747,6 +1791,7 @@ async def save() -> None: reason=reason, execution=dict(self._execution) if self._execution else None, attempts=dict(self._attempts), + prerequisites=self._sidecar_prerequisites_metadata(), ) await self._try_save_sidecar("running", "save_running", save, step_id=current_step) @@ -1769,6 +1814,7 @@ def save() -> None: reason=reason, execution=dict(self._execution) if self._execution else None, attempts=dict(self._attempts), + prerequisites=self._sidecar_prerequisites_metadata(), ) self._try_save_sidecar_sync("running", "save_running_sync", save, step_id=current_step) @@ -1791,6 +1837,7 @@ async def save() -> None: reason="waiting for user input", execution=dict(self._execution) if self._execution else None, attempts=dict(self._attempts), + prerequisites=self._sidecar_prerequisites_metadata(), ) await self._try_save_sidecar("waiting_input", "save_waiting_input", save, step_id=current_step) @@ -1813,6 +1860,7 @@ async def save() -> None: reason=reason, execution=dict(self._execution) if self._execution else None, attempts=dict(self._attempts), + prerequisites=self._sidecar_prerequisites_metadata(), ) await self._try_save_sidecar("completed", "save_completed", save, step_id=current_step) @@ -1836,6 +1884,7 @@ async def save() -> None: reason=reason, execution=dict(self._execution) if self._execution else None, attempts=dict(self._attempts), + prerequisites=self._sidecar_prerequisites_metadata(), ) await self._try_save_sidecar("failed", "save_failed", save, step_id=current_step) @@ -1859,6 +1908,7 @@ def save() -> None: reason=reason, execution=dict(self._execution) if self._execution else None, attempts=dict(self._attempts), + prerequisites=self._sidecar_prerequisites_metadata(), ) self._try_save_sidecar_sync("failed", "save_failed_sync", save, step_id=current_step) @@ -1895,6 +1945,7 @@ def save() -> None: reason=reason, execution=dict(self._execution) if self._execution else None, attempts=dict(self._attempts), + prerequisites=self._sidecar_prerequisites_metadata(), ) self._try_save_sidecar_sync("user_aborted", "save_user_aborted_sync", save, step_id=current_step or None) @@ -1918,6 +1969,7 @@ async def save() -> None: self._pipeline_identity, execution=dict(self._execution) if self._execution else None, attempts=dict(self._attempts), + prerequisites=self._sidecar_prerequisites_metadata(), ) await self._try_save_sidecar("running", "save_rollback", save, step_id=from_step) @@ -1941,6 +1993,7 @@ def save() -> None: self._pipeline_identity, execution=dict(self._execution) if self._execution else None, attempts=dict(self._attempts), + prerequisites=self._sidecar_prerequisites_metadata(), ) self._try_save_sidecar_sync("running", "save_rollback_sync", save, step_id=from_step) @@ -2071,6 +2124,7 @@ def _restored_parallel_prompt_contexts(self, current_step: StepSpec) -> list[Pro memory_content_getter=self._memory_content_getter, auto_trigger_skills=self._auto_trigger_skills, surface=self._surface, + tool_context_env_overrides=self._tool_context_env_overrides, ) self._apply_telemetry_correlation(sub_context_executor) sub_context_dependencies = sub_context_executor._sub_context_dependencies(sub_spec) @@ -2115,6 +2169,7 @@ def _restored_parallel_prompt_contexts(self, current_step: StepSpec) -> list[Pro memory_content_getter=self._memory_content_getter, auto_trigger_skills=self._auto_trigger_skills, surface=self._surface, + tool_context_env_overrides=self._tool_context_env_overrides, ) self._apply_telemetry_correlation(step_executor) agent_context = step_executor.build_agent_loop_context( @@ -3106,12 +3161,15 @@ def _schedule_candidate_restart( state = self._persisted_parallel_candidate_state(idx) if state is None: continue - preserved = { - k: v for k, v in state.get("conclusions", {}).items() if self._conclusion_is_before_step(k, target_step) - } + preserved, preserved_step_conclusions = self._preserved_candidate_conclusions( + sub_spec, + state, + target_step, + ) self._pending_candidate_restarts[idx] = RestartInfo( start_from_step=target_step, preserved_conclusions=preserved, + preserved_step_conclusions=preserved_step_conclusions, rollback_context=rollback_input.display_text if rollback_input is not None else verdict.rollback_context, @@ -3152,6 +3210,9 @@ def _schedule_candidate_restart( context_snapshot, {name: [] for name in field_names}, ) + for field_name, value in preserved.items(): + if field_name in field_names: + sub_context.set_conclusion(field_name, value) for stale_step in sub_spec.steps[target_index:]: sub_context.mark_stale(stale_step.conclusion_field) context_snapshot = sub_context.to_snapshot() @@ -3162,6 +3223,8 @@ def _schedule_candidate_restart( if rollback_input is not None else verdict.rollback_context, } + if preserved_step_conclusions: + pending_restart["preserved_step_conclusions"] = preserved_step_conclusions if rollback_input is not None and rollback_input.has_images: pending_restart["rollback_input"] = _serialize_pipeline_input_content(rollback_input.content) entry = { @@ -3175,6 +3238,7 @@ def _schedule_candidate_restart( "active_attempt_id": new_attempt["attempt_id"], "transcript_id": new_attempt["transcript_id"], "conclusions": preserved, + "step_conclusions": preserved_step_conclusions, "pending_restart": pending_restart, } existing_candidate = self._execution.get("candidates", {}).get(str(idx), {}).get("candidate") @@ -3197,11 +3261,74 @@ def _conclusion_is_before_step(self, conclusion_field: str, step_id: str | None) if not sub_spec: return False step_ids = [s.step_id for s in sub_spec.steps] - field_to_step = {s.conclusion_field: s.step_id for s in sub_spec.steps} - owning_step = field_to_step.get(conclusion_field) - if not owning_step or step_id not in step_ids: + if step_id not in step_ids: return False - return step_ids.index(owning_step) < step_ids.index(step_id) + target_index = step_ids.index(step_id) + writer_indices = [idx for idx, step in enumerate(sub_spec.steps) if step.conclusion_field == conclusion_field] + return bool(writer_indices) and max(writer_indices) < target_index + + def _preserved_candidate_conclusions( + self, + sub_spec: Any, + state: dict[str, Any], + target_step: str | None, + ) -> tuple[dict[str, Any], dict[str, Any]]: + if sub_spec is None or target_step is None: + return {}, {} + step_conclusions = state.get("step_conclusions") + if isinstance(step_conclusions, dict): + preserved_step_conclusions = self._step_conclusions_before_step( + sub_spec, + target_step, + step_conclusions, + ) + return ( + self._field_conclusions_from_step_conclusions(sub_spec, preserved_step_conclusions), + preserved_step_conclusions, + ) + conclusions = state.get("conclusions") + if not isinstance(conclusions, dict): + return {}, {} + preserved = { + field: value for field, value in conclusions.items() if self._conclusion_is_before_step(field, target_step) + } + return preserved, self._infer_legacy_step_conclusions(sub_spec, preserved) + + def _step_conclusions_before_step( + self, + sub_spec: Any, + target_step: str, + step_conclusions: dict[str, Any], + ) -> dict[str, Any]: + step_ids = [step.step_id for step in sub_spec.steps] + if target_step not in step_ids: + return {} + target_index = step_ids.index(target_step) + return { + step.step_id: step_conclusions[step.step_id] + for step in sub_spec.steps[:target_index] + if step.step_id in step_conclusions + } + + @staticmethod + def _field_conclusions_from_step_conclusions( + sub_spec: Any, + step_conclusions: dict[str, Any], + ) -> dict[str, Any]: + conclusions: dict[str, Any] = {} + for step in sub_spec.steps: + if step.step_id in step_conclusions: + conclusions[step.conclusion_field] = step_conclusions[step.step_id] + return conclusions + + @staticmethod + def _infer_legacy_step_conclusions(sub_spec: Any, conclusions: dict[str, Any]) -> dict[str, Any]: + step_conclusions: dict[str, Any] = {} + for conclusion_field, value in conclusions.items(): + writers = [step for step in sub_spec.steps if step.conclusion_field == conclusion_field] + if len(writers) == 1: + step_conclusions[writers[0].step_id] = value + return step_conclusions async def _continue_from_current( self, @@ -3986,6 +4113,7 @@ async def _execute_parallel_sub_pipeline( auto_trigger_skills=self._auto_trigger_skills, surface=self._surface, backup_service=self._backup_service, + tool_context_env_overrides=self._tool_context_env_overrides, ) for _ in candidates ] @@ -4043,6 +4171,9 @@ async def save_candidate_execution_state( conclusions = payload.get("conclusions") if conclusions is not None: entry["conclusions"] = conclusions + step_conclusions = payload.get("step_conclusions") + if step_conclusions is not None: + entry["step_conclusions"] = step_conclusions active_state = self._active_candidates.get(i) pending_ask_resume = ( active_state.get(_PENDING_ASK_USER_QUESTION_RESUME_KEY) if isinstance(active_state, dict) else None @@ -4064,6 +4195,7 @@ async def save_candidate_completed(i: int, state: dict[str, Any], conclusions: d "active_attempt_id": state.get("active_attempt_id"), "transcript_id": state.get("transcript_id"), "conclusions": conclusions, + "step_conclusions": state.get("step_conclusions", {}), } self._execution.setdefault("candidates", {})[str(i)] = entry await self._save_running(step.step_id, reason="parallel candidate completed") @@ -4083,6 +4215,7 @@ async def save_candidate_failed(i: int, state: dict[str, Any]) -> None: "active_attempt_id": active_attempt_id, "transcript_id": state.get("transcript_id"), "conclusions": state.get("conclusions", {}), + "step_conclusions": state.get("step_conclusions", {}), } if state.get("error") is not None: entry["error"] = state["error"] @@ -4104,6 +4237,9 @@ async def run_candidate( "task": asyncio.current_task(), "current_sub_step": "", "conclusions": restart_info.preserved_conclusions if restart_info else {}, + "step_conclusions": restart_info.preserved_step_conclusions + if restart_info + else (resume_state or {}).get("step_conclusions", {}), "name": candidate.get("name", _("Candidate {index}").format(index=i + 1)), "agent_loop": None, "sub_pipeline_id": (resume_state or {}).get("sub_pipeline_id") or default_sub_pipeline_id, @@ -4148,6 +4284,7 @@ async def record_sub_step_state(payload: dict[str, Any]) -> None: state["active_attempt_id"] = None state["transcript_id"] = None state["conclusions"] = payload.get("conclusions", state.get("conclusions", {})) + state["step_conclusions"] = payload.get("step_conclusions", state.get("step_conclusions", {})) await save_candidate_execution_state( i, payload, @@ -4158,6 +4295,7 @@ async def record_sub_step_state(payload: dict[str, Any]) -> None: execute_streaming = sub_executors[i].execute_streaming start_from_step = restart_info.start_from_step if restart_info else None preserved_conclusions = restart_info.preserved_conclusions if restart_info else None + preserved_step_conclusions = restart_info.preserved_step_conclusions if restart_info else None candidate_user_message = ( restart_info.rollback_input if restart_info and restart_info.rollback_input is not None @@ -4174,6 +4312,19 @@ async def record_sub_step_state(payload: dict[str, Any]) -> None: has_var_keyword = any( parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() ) + stream_kwargs: dict[str, Any] = { + "sub_spec": sub_spec, + "candidate": candidate, + "candidate_index": i, + "parent_context": self.context, + "session_id": self._session_id, + "start_from_step": start_from_step, + "preserved_conclusions": preserved_conclusions, + "user_message": candidate_user_message, + "parent_step_id": step.step_id, + } + if "preserved_step_conclusions" in parameters or has_var_keyword: + stream_kwargs["preserved_step_conclusions"] = preserved_step_conclusions supports_recovery_kwargs = "resume_state" in parameters or has_var_keyword if supports_recovery_kwargs: recovery_kwargs: dict[str, Any] = { @@ -4185,30 +4336,9 @@ async def record_sub_step_state(payload: dict[str, Any]) -> None: recovery_kwargs["precompleted_tools"] = candidate_precompleted_tools if "resume_messages" in parameters or has_var_keyword: recovery_kwargs["resume_messages"] = candidate_resume_messages - event_stream = execute_streaming( - sub_spec=sub_spec, - candidate=candidate, - candidate_index=i, - parent_context=self.context, - session_id=self._session_id, - start_from_step=start_from_step, - preserved_conclusions=preserved_conclusions, - user_message=candidate_user_message, - parent_step_id=step.step_id, - **recovery_kwargs, - ) + event_stream = execute_streaming(**stream_kwargs, **recovery_kwargs) else: - event_stream = execute_streaming( - sub_spec=sub_spec, - candidate=candidate, - candidate_index=i, - parent_context=self.context, - session_id=self._session_id, - start_from_step=start_from_step, - preserved_conclusions=preserved_conclusions, - user_message=candidate_user_message, - parent_step_id=step.step_id, - ) + event_stream = execute_streaming(**stream_kwargs) async for event in event_stream: if isinstance(event, PipelineEvent) and event.type == PipelineEventType.SUB_PIPELINE_STARTED: state["sub_pipeline_id"] = event.data.get("sub_pipeline_id") or default_sub_pipeline_id @@ -4224,6 +4354,10 @@ async def record_sub_step_state(payload: dict[str, Any]) -> None: ): conclusions_by_index[i] = event.data.get("conclusions", {}) state["conclusions"] = conclusions_by_index[i] + state["step_conclusions"] = event.data.get( + "step_conclusions", + state.get("step_conclusions", {}), + ) await save_candidate_completed(i, state, conclusions_by_index[i]) if ( isinstance(event, PipelineEvent) diff --git a/src/iac_code/pipeline/engine/prerequisites.py b/src/iac_code/pipeline/engine/prerequisites.py new file mode 100644 index 00000000..0a724b8c --- /dev/null +++ b/src/iac_code/pipeline/engine/prerequisites.py @@ -0,0 +1,1801 @@ +"""Pipeline prerequisite resolution helpers.""" + +from __future__ import annotations + +import hashlib +import http.client +import inspect +import json +import os +import platform +import queue +import re +import shlex +import shutil +import signal +import subprocess +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Callable, Mapping +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +from iac_code.i18n import _ + +CommandExists = Callable[[str], object] +RunCommand = Callable[..., "CommandResult"] +ChooseInstaller = Callable[[str, list["InstallerSpec"]], str | None] +ProgressHandler = Callable[["PrerequisiteProgress"], None] + +_MAX_FAILURE_MESSAGE_CHARS = 1200 +_MAX_FAILURE_MESSAGE_LINES = 14 +_DOWNLOAD_CHUNK_SIZE = 256 * 1024 +_DOWNLOAD_PROGRESS_INTERVAL = 1024 * 1024 +_DEFAULT_PROBE_TIMEOUT_SECONDS = 30.0 + + +@dataclass +class CommandResult: + command: list[str] + returncode: int + stdout: str + stderr: str + + +@dataclass +class PrerequisiteProgress: + name: str + installer_id: str | None + phase: str + status: str + message: str + installer_display_key: str | None = None + installer_display_name: str | None = None + command: list[str] = field(default_factory=list) + stream: str | None = None + downloaded_bytes: int | None = None + total_bytes: int | None = None + + +@dataclass +class InstallerSpec: + id: str + platforms: list[str] + display_key: str | None = None + display_name: str | None = None + requires_commands: list[str] = field(default_factory=list) + commands: list[list[Any]] = field(default_factory=list) + path_hints: list[dict[str, Any]] = field(default_factory=list) + env: dict[str, str] = field(default_factory=dict) + download: dict[str, Any] = field(default_factory=dict) + timeout_seconds: float | None = None + + +@dataclass +class PrerequisiteDecision: + name: str + command: str + status: str + required_flags: list[str] + resolved_path: str | None = None + installer_id: str | None = None + message: str = "" + + +@dataclass +class PrerequisiteResolution: + feature_flags: dict[str, bool] + decisions: dict[str, PrerequisiteDecision] + env_overrides: dict[str, str] = field(default_factory=dict) + + def to_metadata(self) -> dict[str, Any]: + return asdict(self) + + +def inspect_prerequisites( + raw_prerequisites: Mapping[str, Mapping[str, Any]], + *, + feature_flags: Mapping[str, bool], + command_exists: CommandExists | None = None, + run_command: RunCommand | None = None, +) -> PrerequisiteResolution: + checker = command_exists or _default_command_exists + runner = run_command or _default_run_command + resolved_flags = dict(feature_flags) + decisions: dict[str, PrerequisiteDecision] = {} + + for name, raw_prerequisite in raw_prerequisites.items(): + command = str(raw_prerequisite.get("command", name)) + required_flags = list(raw_prerequisite.get("required_by_flags") or []) + + if not _has_enabled_required_flag(required_flags, resolved_flags): + decisions[name] = PrerequisiteDecision( + name=name, + command=command, + status="skipped", + required_flags=required_flags, + ) + continue + + exists, resolved_path = _check_command(command, checker) + if exists: + version_ok, version_message = _check_prerequisite_version( + raw_prerequisite, + name=name, + command=command, + resolved_path=resolved_path, + run_command=runner, + env_overrides={}, + installer_id=None, + progress_handler=None, + ) + if not version_ok: + decisions[name] = _non_interactive_missing_decision( + raw_prerequisite, + resolved_flags, + name=name, + command=command, + required_flags=required_flags, + resolved_path=resolved_path, + message=version_message, + ) + continue + decisions[name] = PrerequisiteDecision( + name=name, + command=command, + status="available", + required_flags=required_flags, + resolved_path=resolved_path, + ) + continue + + if _on_missing_action(raw_prerequisite, "non_interactive") == "disable_feature": + _disable_flags(resolved_flags, required_flags) + decisions[name] = PrerequisiteDecision( + name=name, + command=command, + status="disabled_feature", + required_flags=required_flags, + ) + continue + + decisions[name] = PrerequisiteDecision( + name=name, + command=command, + status="missing", + required_flags=required_flags, + ) + + return PrerequisiteResolution(feature_flags=resolved_flags, decisions=decisions) + + +def prepare_prerequisites( + raw_prerequisites: Mapping[str, Mapping[str, Any]], + *, + feature_flags: Mapping[str, bool], + surface: str, + platform_system: str | None = None, + platform_machine: str | None = None, + command_exists: CommandExists | None = None, + run_command: RunCommand | None = None, + choose_installer: ChooseInstaller | None = None, + progress_handler: ProgressHandler | None = None, +) -> PrerequisiteResolution: + checker = command_exists or _default_command_exists + runner = run_command or _default_run_command + chooser = choose_installer or _default_choose_installer + current_platform = _normalize_platform(platform_system or platform.system()) + current_architecture = _normalize_architecture(platform_machine or platform.machine()) + resolved_flags = dict(feature_flags) + decisions: dict[str, PrerequisiteDecision] = {} + env_overrides: dict[str, str] = {} + + for name, raw_prerequisite in raw_prerequisites.items(): + command = str(raw_prerequisite.get("command", name)) + required_flags = list(raw_prerequisite.get("required_by_flags") or []) + + if not _has_enabled_required_flag(required_flags, resolved_flags): + decisions[name] = PrerequisiteDecision( + name=name, + command=command, + status="skipped", + required_flags=required_flags, + ) + continue + + version_failure_message = "" + exists, resolved_path = _check_command(command, checker) + if exists: + version_ok, version_message = _check_prerequisite_version( + raw_prerequisite, + name=name, + command=command, + resolved_path=resolved_path, + run_command=runner, + env_overrides=env_overrides, + installer_id=None, + progress_handler=progress_handler, + ) + if version_ok: + decisions[name] = PrerequisiteDecision( + name=name, + command=command, + status="available", + required_flags=required_flags, + resolved_path=resolved_path, + ) + continue + version_failure_message = version_message + + available_installers = _available_installers(raw_prerequisite, current_platform, current_architecture, checker) + resolved_path, resolved_installer_id, hint_version_message = _resolve_path_hint_from_installers( + raw_prerequisite, + name, + command, + available_installers, + runner, + env_overrides, + current_platform, + progress_handler, + ) + if hint_version_message and not version_failure_message: + version_failure_message = hint_version_message + if resolved_path is not None: + decisions[name] = PrerequisiteDecision( + name=name, + command=command, + status="available", + required_flags=required_flags, + resolved_path=resolved_path, + installer_id=resolved_installer_id, + ) + continue + + if str(surface) != "repl" or _on_missing_action(raw_prerequisite, "repl") != "prompt_install": + decisions[name] = _non_interactive_missing_decision( + raw_prerequisite, + resolved_flags, + name=name, + command=command, + required_flags=required_flags, + message=version_failure_message, + ) + continue + + selected_installer_id = chooser(name, available_installers) + selected_installer = _find_installer(available_installers, selected_installer_id) + if selected_installer is None: + _disable_flags(resolved_flags, required_flags) + decisions[name] = PrerequisiteDecision( + name=name, + command=command, + status="declined_or_unavailable", + required_flags=required_flags, + message=version_failure_message, + ) + continue + + install_result, installed_path = _run_installer( + name, + command, + selected_installer, + runner, + env_overrides, + current_platform, + current_architecture, + progress_handler, + ) + if install_result is not None: + _disable_flags(resolved_flags, required_flags) + decisions[name] = PrerequisiteDecision( + name=name, + command=command, + status="install_failed", + required_flags=required_flags, + installer_id=selected_installer.id, + message=_failure_message(install_result), + ) + continue + + if installed_path is not None: + exists = True + resolved_path = installed_path + else: + exists, resolved_path = _check_command(command, checker) + if not exists: + resolved_path, version_failure_message = _resolve_path_hints( + raw_prerequisite, + name, + command, + selected_installer.id, + selected_installer.display_key, + selected_installer.display_name, + selected_installer.path_hints, + runner, + env_overrides, + current_platform, + progress_handler, + ) + exists = resolved_path is not None + + if not exists: + _disable_flags(resolved_flags, required_flags) + decisions[name] = PrerequisiteDecision( + name=name, + command=command, + status="missing_after_install", + required_flags=required_flags, + installer_id=selected_installer.id, + message=version_failure_message, + ) + continue + + version_ok, version_message = _check_prerequisite_version( + raw_prerequisite, + name=name, + command=command, + resolved_path=resolved_path, + run_command=runner, + env_overrides=env_overrides, + installer_id=selected_installer.id, + installer_display_key=selected_installer.display_key, + installer_display_name=selected_installer.display_name, + progress_handler=progress_handler, + ) + if not version_ok: + _disable_flags(resolved_flags, required_flags) + decisions[name] = PrerequisiteDecision( + name=name, + command=command, + status="install_failed", + required_flags=required_flags, + resolved_path=resolved_path, + installer_id=selected_installer.id, + message=version_message, + ) + continue + + post_install_result = _run_post_install( + raw_prerequisite, + name, + selected_installer.id, + selected_installer.display_key, + selected_installer.display_name, + runner, + env_overrides, + progress_handler, + ) + if post_install_result is not None: + _disable_flags(resolved_flags, required_flags) + decisions[name] = PrerequisiteDecision( + name=name, + command=command, + status="post_install_failed", + required_flags=required_flags, + resolved_path=resolved_path, + installer_id=selected_installer.id, + message=_failure_message(post_install_result), + ) + continue + + decisions[name] = PrerequisiteDecision( + name=name, + command=command, + status="available", + required_flags=required_flags, + resolved_path=resolved_path, + installer_id=selected_installer.id, + ) + + return PrerequisiteResolution(feature_flags=resolved_flags, decisions=decisions, env_overrides=env_overrides) + + +def _default_command_exists(command: str) -> str | None: + return shutil.which(command) + + +def _default_run_command( + command: list[str], + env: Mapping[str, str] | None = None, + on_output: Callable[[str, str], None] | None = None, + timeout_seconds: float | None = None, +) -> CommandResult: + popen_kwargs: dict[str, Any] = {} + if os.name == "nt": + popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + else: + popen_kwargs["start_new_session"] = True + try: + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + env=dict(env) if env is not None else None, + **popen_kwargs, + ) + except OSError as exc: + return CommandResult(command=command, returncode=127, stdout="", stderr=str(exc)) + + output_parts: list[str] = [] + assert process.stdout is not None + stdout = process.stdout + output_queue: queue.Queue[tuple[str, str | None]] = queue.Queue() + + def read_stdout() -> None: + try: + for line in stdout: + output_queue.put(("line", line)) + finally: + output_queue.put(("done", None)) + + reader = threading.Thread(target=read_stdout, daemon=True) + reader.start() + deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None and timeout_seconds > 0 else None + reader_done = False + try: + while True: + if deadline is None: + wait_timeout = 0.1 + else: + remaining = deadline - time.monotonic() + if remaining <= 0: + _terminate_process(process) + _drain_command_output(output_queue, output_parts, on_output) + return CommandResult( + command=command, + returncode=124, + stdout="".join(output_parts), + stderr=_("command timed out after {seconds} seconds").format( + seconds=_format_timeout_seconds(timeout_seconds or 0) + ), + ) + wait_timeout = min(0.1, remaining) + + try: + kind, payload = output_queue.get(timeout=wait_timeout) + except queue.Empty: + kind, payload = "", None + + if kind == "line" and payload is not None: + output_parts.append(payload) + if on_output is not None: + on_output("stdout", payload.rstrip("\n")) + elif kind == "done": + reader_done = True + + returncode = process.poll() + if returncode is not None and (reader_done or output_queue.empty()): + _drain_command_output(output_queue, output_parts, on_output) + return CommandResult( + command=command, + returncode=returncode, + stdout="".join(output_parts), + stderr="", + ) + except KeyboardInterrupt: + _terminate_process(process) + raise + + +def _drain_command_output( + output_queue: queue.Queue[tuple[str, str | None]], + output_parts: list[str], + on_output: Callable[[str, str], None] | None, +) -> None: + while True: + try: + kind, payload = output_queue.get_nowait() + except queue.Empty: + return + if kind != "line" or payload is None: + continue + output_parts.append(payload) + if on_output is not None: + on_output("stdout", payload.rstrip("\n")) + + +def _terminate_process(process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + if os.name == "nt": + _terminate_windows_process_tree(process) + else: + _terminate_posix_process_group(process) + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + if os.name == "nt": + _terminate_windows_process_tree(process, force=True) + else: + _terminate_posix_process_group(process, force=True) + process.wait() + + +def _terminate_posix_process_group(process: subprocess.Popen[str], *, force: bool = False) -> None: + try: + os.killpg(process.pid, signal.SIGKILL if force else signal.SIGTERM) + except ProcessLookupError: + return + except OSError: + if force: + process.kill() + else: + process.terminate() + + +def _terminate_windows_process_tree(process: subprocess.Popen[str], *, force: bool = False) -> None: + command = ["taskkill", "/T", "/PID", str(process.pid)] + if force: + command.insert(1, "/F") + try: + subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + except OSError: + if force: + process.kill() + else: + process.terminate() + + +def _default_choose_installer(_name: str, _installers: list[InstallerSpec]) -> str | None: + return None + + +def _check_command(command: str, command_exists: CommandExists) -> tuple[bool, str | None]: + result = command_exists(command) + if isinstance(result, bool): + return result, command if result else None + if isinstance(result, (str, os.PathLike)): + return True, os.fspath(result) + if result: + return True, command + return False, None + + +def _has_enabled_required_flag(required_flags: list[str], feature_flags: Mapping[str, bool]) -> bool: + return any(feature_flags.get(flag, False) for flag in required_flags) + + +def _disable_flags(feature_flags: dict[str, bool], required_flags: list[str]) -> None: + for flag in required_flags: + feature_flags[flag] = False + + +def _on_missing_action(raw_prerequisite: Mapping[str, Any], surface: str) -> str | None: + on_missing = raw_prerequisite.get("on_missing") or {} + if isinstance(on_missing, Mapping): + action = on_missing.get(surface) + return str(action) if action is not None else None + return None + + +def _non_interactive_missing_decision( + raw_prerequisite: Mapping[str, Any], + feature_flags: dict[str, bool], + *, + name: str, + command: str, + required_flags: list[str], + resolved_path: str | None = None, + message: str = "", +) -> PrerequisiteDecision: + if _on_missing_action(raw_prerequisite, "non_interactive") == "disable_feature": + _disable_flags(feature_flags, required_flags) + return PrerequisiteDecision( + name=name, + command=command, + status="disabled_feature", + required_flags=required_flags, + resolved_path=resolved_path, + message=message, + ) + return PrerequisiteDecision( + name=name, + command=command, + status="missing", + required_flags=required_flags, + resolved_path=resolved_path, + message=message, + ) + + +def _normalize_platform(platform_system: str) -> str: + normalized = platform_system.lower() + if normalized == "darwin": + return "darwin" + if normalized.startswith("linux"): + return "linux" + if normalized.startswith(("win", "cygwin", "msys")): + return "windows" + return normalized + + +def _normalize_architecture(machine: str) -> str: + normalized = machine.lower().replace("_", "-") + if normalized in {"x86-64", "x64"}: + return "amd64" + if normalized in {"amd64", "x86_64"}: + return "amd64" + if normalized in {"aarch64", "arm64"}: + return "arm64" + return normalized + + +def _available_installers( + raw_prerequisite: Mapping[str, Any], + current_platform: str, + current_architecture: str, + command_exists: CommandExists, +) -> list[InstallerSpec]: + installers = [_installer_from_raw(raw_installer) for raw_installer in raw_prerequisite.get("installers") or []] + return [ + installer + for installer in installers + if _installer_matches_platform(installer, current_platform, current_architecture) + and all(_check_command(required_command, command_exists)[0] for required_command in installer.requires_commands) + ] + + +def _installer_matches_platform(installer: InstallerSpec, current_platform: str, current_architecture: str) -> bool: + if current_platform not in installer.platforms: + return False + if installer.download: + asset = _select_download_asset(installer.download, current_platform, current_architecture) + if asset is None or not _download_urls(asset) or not _download_sha256(asset): + return False + return True + + +def _installer_from_raw(raw_installer: Mapping[str, Any]) -> InstallerSpec: + return InstallerSpec( + id=str(raw_installer["id"]), + platforms=list(raw_installer.get("platforms") or []), + display_key=str(raw_installer["display_key"]) if raw_installer.get("display_key") else None, + display_name=str(raw_installer["display_name"]) if raw_installer.get("display_name") else None, + requires_commands=list(raw_installer.get("requires_commands") or []), + commands=[list(command) for command in raw_installer.get("commands") or []], + path_hints=[dict(path_hint) for path_hint in raw_installer.get("path_hints") or []], + env={str(key): str(value) for key, value in (raw_installer.get("env") or {}).items()}, + download=dict(raw_installer.get("download") or {}), + timeout_seconds=_timeout_seconds(raw_installer.get("timeout_seconds")), + ) + + +def _find_installer(installers: list[InstallerSpec], installer_id: str | None) -> InstallerSpec | None: + if installer_id is None: + return None + return next((installer for installer in installers if installer.id == installer_id), None) + + +def _resolve_path_hint_from_installers( + raw_prerequisite: Mapping[str, Any], + name: str, + command: str, + installers: list[InstallerSpec], + run_command: RunCommand, + env_overrides: dict[str, str], + current_platform: str, + progress_handler: ProgressHandler | None, +) -> tuple[str | None, str | None, str]: + version_failure_message = "" + for installer in installers: + resolved_path, install_dir = _resolve_download_installed_path( + installer, + command, + current_platform, + ) + if resolved_path is not None: + version_ok, version_message = _check_prerequisite_version( + raw_prerequisite, + name=name, + command=command, + resolved_path=resolved_path, + run_command=run_command, + env_overrides=env_overrides, + installer_id=installer.id, + installer_display_key=installer.display_key, + installer_display_name=installer.display_name, + progress_handler=progress_handler, + ) + if version_ok: + if install_dir is not None: + _prepend_path(env_overrides, install_dir) + return resolved_path, installer.id, "" + if version_message: + version_failure_message = version_message + + resolved_path, version_message = _resolve_path_hints( + raw_prerequisite, + name, + command, + installer.id, + installer.display_key, + installer.display_name, + installer.path_hints, + run_command, + env_overrides, + current_platform, + progress_handler, + ) + if resolved_path is not None: + return resolved_path, installer.id, "" + if version_message: + version_failure_message = version_message + return None, None, version_failure_message + + +def _resolve_download_installed_path( + installer: InstallerSpec, + command: str, + current_platform: str, +) -> tuple[str | None, str | None]: + if not installer.download: + return None, None + install_dir = _expanded_path(str(installer.download.get("install_dir") or "~/bin")) + installed_name = str(installer.download.get("installed_name") or command) + installed_path = install_dir / _installed_executable_name(installed_name, current_platform) + if not _is_executable(installed_path): + return None, None + return str(installed_path), str(install_dir) + + +def _run_installer( + name: str, + command_name: str, + installer: InstallerSpec, + run_command: RunCommand, + env_overrides: dict[str, str], + current_platform: str, + current_architecture: str, + progress_handler: ProgressHandler | None, +) -> tuple[CommandResult | None, str | None]: + installer_env = dict(env_overrides) + installer_env.update(_expand_env_mapping(installer.env)) + command_result = _run_commands( + installer.commands, + run_command, + installer_env, + name=name, + installer_id=installer.id, + installer_display_key=installer.display_key, + installer_display_name=installer.display_name, + phase="install", + progress_handler=progress_handler, + timeout_seconds=installer.timeout_seconds, + ) + if command_result is not None: + return command_result, None + + if installer.download: + return _download_configured_asset( + name, + command_name, + installer, + env_overrides, + current_platform, + current_architecture, + progress_handler, + ) + return None, None + + +def _run_commands( + commands: list[list[Any]], + run_command: RunCommand, + env_overrides: Mapping[str, str], + *, + name: str, + installer_id: str | None, + phase: str, + progress_handler: ProgressHandler | None, + installer_display_key: str | None = None, + installer_display_name: str | None = None, + timeout_seconds: float | None = None, +) -> CommandResult | None: + for raw_command in commands: + command = _resolve_command(raw_command) + _emit_progress( + progress_handler, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + phase=phase, + status="started", + message=_("Running {command}").format(command=_format_command(command)), + command=command, + ) + result = _call_run_command( + run_command, + command, + env_overrides, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + phase=phase, + progress_handler=progress_handler, + timeout_seconds=timeout_seconds, + ) + if result.returncode != 0: + _emit_progress( + progress_handler, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + phase=phase, + status="failed", + message=_failure_message(result), + command=command, + ) + return result + _emit_progress( + progress_handler, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + phase=phase, + status="succeeded", + message=_("Finished {command}").format(command=_format_command(command)), + command=command, + ) + return None + + +def _resolve_command(raw_command: list[Any]) -> list[str]: + return [_resolve_command_part(part) for part in raw_command] + + +def _resolve_command_part(part: Any) -> str: + if not isinstance(part, Mapping): + return str(part) + + env_name = part.get("env") + if isinstance(env_name, str): + env_value = os.environ.get(env_name) + if env_value: + return env_value + + if part.get("kind") == "go-install-latest-github-tag": + return _resolve_latest_github_tag_go_install_target(part) + + default_value = part.get("default") + if default_value is not None: + return str(default_value) + return str(part) + + +def _resolve_latest_github_tag_go_install_target(config: Mapping[str, Any]) -> str: + module = str(config.get("module") or "").strip() + if not module: + return str(config) + + fallback_ref = str(config.get("fallback_ref") or "").strip() + try: + latest_ref = _latest_github_tag_commit_ref(config) + except Exception: + latest_ref = "" + + ref = latest_ref or fallback_ref + if not ref: + return module + return f"{module}@{ref}" + + +def _latest_github_tag_commit_ref(config: Mapping[str, Any]) -> str: + repo = str(config.get("repo") or "").strip() + tag_prefix = str(config.get("tag_prefix") or "").strip() + if not repo or not tag_prefix: + return "" + timeout = float(config.get("timeout_seconds") or 10) + git_ref = _latest_git_ls_remote_tag_commit_ref(repo, tag_prefix, timeout) + if git_ref: + return git_ref + refs_url = "https://api.github.com/repos/{repo}/git/matching-refs/tags/{tag_prefix}".format( + repo=repo, + tag_prefix=urllib.parse.quote(tag_prefix, safe="/"), + ) + refs = _read_json_url(refs_url, timeout=timeout) + if not isinstance(refs, list): + return "" + + best_ref: Mapping[str, Any] | None = None + best_version: list[int] = [] + full_prefix = f"refs/tags/{tag_prefix}" + for raw_ref in refs: + if not isinstance(raw_ref, Mapping): + continue + ref_name = str(raw_ref.get("ref") or "") + if not ref_name.startswith(full_prefix): + continue + version = ref_name[len(full_prefix) :] + version_parts = _version_parts(version) + if best_ref is None or version_parts > best_version: + best_ref = raw_ref + best_version = version_parts + + if best_ref is None: + return "" + raw_object = best_ref.get("object") + if not isinstance(raw_object, Mapping): + return "" + return _github_ref_object_commit_sha(repo, raw_object, timeout) + + +def _github_ref_object_commit_sha(repo: str, raw_object: Mapping[str, Any], timeout: float) -> str: + object_type = str(raw_object.get("type") or "") + sha = str(raw_object.get("sha") or "") + if not sha: + return "" + if object_type == "commit": + return sha + if object_type != "tag": + return "" + tag_url = "https://api.github.com/repos/{repo}/git/tags/{sha}".format(repo=repo, sha=sha) + tag = _read_json_url(tag_url, timeout=timeout) + if not isinstance(tag, Mapping): + return "" + tag_object = tag.get("object") + if not isinstance(tag_object, Mapping): + return "" + if str(tag_object.get("type") or "") != "commit": + return "" + return str(tag_object.get("sha") or "") + + +def _latest_git_ls_remote_tag_commit_ref(repo: str, tag_prefix: str, timeout: float) -> str: + try: + completed = subprocess.run( + [ + "git", + "ls-remote", + "--tags", + f"https://github.com/{repo}.git", + f"refs/tags/{tag_prefix}*", + ], + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + except (OSError, subprocess.SubprocessError): + return "" + if completed.returncode != 0: + return "" + + tags: dict[str, dict[str, str]] = {} + for line in completed.stdout.splitlines(): + parts = line.split() + if len(parts) != 2: + continue + sha, ref = parts + peeled = ref.endswith("^{}") + if peeled: + ref = ref[:-3] + prefix = f"refs/tags/{tag_prefix}" + if not ref.startswith(prefix): + continue + version = ref[len(prefix) :] + entry = tags.setdefault(version, {}) + entry["commit" if peeled else "tag"] = sha + + if not tags: + return "" + latest_version = max(tags, key=_version_parts) + latest = tags[latest_version] + return latest.get("commit") or latest.get("tag") or "" + + +def _read_json_url(url: str, *, timeout: float) -> Any: + with urllib.request.urlopen(url, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def _run_post_install( + raw_prerequisite: Mapping[str, Any], + name: str, + installer_id: str | None, + installer_display_key: str | None, + installer_display_name: str | None, + run_command: RunCommand, + env_overrides: Mapping[str, str], + progress_handler: ProgressHandler | None, +) -> CommandResult | None: + post_install = raw_prerequisite.get("post_install") or {} + if not isinstance(post_install, Mapping): + return None + commands = [list(command) for command in post_install.get("commands") or []] + timeout_seconds = _timeout_seconds(post_install.get("timeout_seconds")) + return _run_commands( + commands, + run_command, + env_overrides, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + phase="post_install", + progress_handler=progress_handler, + timeout_seconds=timeout_seconds, + ) + + +def _resolve_path_hints( + raw_prerequisite: Mapping[str, Any], + name: str, + command: str, + installer_id: str | None, + installer_display_key: str | None, + installer_display_name: str | None, + path_hints: list[dict[str, Any]], + run_command: RunCommand, + env_overrides: dict[str, str], + current_platform: str, + progress_handler: ProgressHandler | None, +) -> tuple[str | None, str]: + version_failure_message = "" + for path_hint in path_hints: + if path_hint.get("kind") != "command_output": + continue + hint_command = path_hint.get("command") + if not isinstance(hint_command, list): + continue + result = _call_run_command( + run_command, + [str(part) for part in hint_command], + env_overrides, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + phase="path_hint", + progress_handler=progress_handler, + timeout_seconds=_probe_timeout_seconds(path_hint.get("timeout_seconds")), + ) + if result.returncode != 0: + continue + output_lines = result.stdout.strip().splitlines() + if not output_lines: + continue + directory = Path(output_lines[0]) + append = path_hint.get("append") + if append: + directory = directory / str(append) + for executable in _executable_candidates(directory, command, current_platform): + if _is_executable(executable): + version_ok, version_message = _check_prerequisite_version( + raw_prerequisite, + name=name, + command=command, + resolved_path=str(executable), + run_command=run_command, + env_overrides=env_overrides, + installer_id=installer_id, + progress_handler=progress_handler, + ) + if not version_ok: + if version_message: + version_failure_message = version_message + continue + _prepend_path(env_overrides, str(directory)) + return str(executable), "" + return None, version_failure_message + + +def _executable_candidates(directory: Path, command: str, current_platform: str) -> list[Path]: + candidates = [directory / command] + if current_platform != "windows": + return candidates + + suffixes = _windows_executable_suffixes() + command_lower = command.lower() + if any(command_lower.endswith(suffix) for suffix in suffixes): + return candidates + for suffix in suffixes: + candidates.append(directory / f"{command}{suffix}") + return candidates + + +def _windows_executable_suffixes() -> list[str]: + extensions = [".exe", ".bat", ".cmd", ".com"] + raw_extensions = os.environ.get("PATHEXT") + if raw_extensions: + extensions.extend(extension.strip().lower() for extension in raw_extensions.split(";") if extension.strip()) + return list(dict.fromkeys(extension if extension.startswith(".") else f".{extension}" for extension in extensions)) + + +def _check_prerequisite_version( + raw_prerequisite: Mapping[str, Any], + *, + name: str, + command: str, + resolved_path: str | None, + run_command: RunCommand, + env_overrides: Mapping[str, str], + installer_id: str | None, + progress_handler: ProgressHandler | None, + installer_display_key: str | None = None, + installer_display_name: str | None = None, +) -> tuple[bool, str]: + version_check = raw_prerequisite.get("version_check") + if not isinstance(version_check, Mapping): + return True, "" + + minimum = str(version_check.get("minimum") or "").strip() + if not minimum: + return True, "" + + version_command = _version_check_command(version_check, command, resolved_path) + result = _call_run_command( + run_command, + version_command, + env_overrides, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + phase="version_check", + progress_handler=progress_handler, + timeout_seconds=_probe_timeout_seconds(version_check.get("timeout_seconds")), + ) + if result.returncode != 0: + return False, _("Version check failed for {name}: {reason}").format( + name=name, + reason=_failure_message(result), + ) + + output = "\n".join(part for part in (result.stdout, result.stderr) if part) + version = _extract_version(output, version_check) + if version is None: + return False, _("Could not determine {name} version from output.").format(name=name) + + if _compare_versions(version, minimum) < 0: + return False, _("{name} version {version} is lower than required {minimum}.").format( + minimum=minimum, + name=name, + version=version, + ) + return True, "" + + +def _version_check_command( + version_check: Mapping[str, Any], + command: str, + resolved_path: str | None, +) -> list[str]: + raw_command = version_check.get("command") + if not isinstance(raw_command, list) or not raw_command: + raw_command = [command, "version"] + version_command = [str(part) for part in raw_command] + if resolved_path and version_command and version_command[0] == command: + version_command[0] = resolved_path + return version_command + + +def _extract_version(output: str, version_check: Mapping[str, Any]) -> str | None: + pattern = str(version_check.get("pattern") or r"(?P\d+(?:\.\d+){1,3})") + match = re.search(pattern, output) + if match is None: + return None + group_dict = match.groupdict() + if "version" in group_dict: + return group_dict["version"] + if match.lastindex: + return match.group(1) + return match.group(0) + + +def _compare_versions(actual: str, minimum: str) -> int: + actual_parts = _version_parts(actual) + minimum_parts = _version_parts(minimum) + width = max(len(actual_parts), len(minimum_parts)) + actual_parts.extend([0] * (width - len(actual_parts))) + minimum_parts.extend([0] * (width - len(minimum_parts))) + if actual_parts < minimum_parts: + return -1 + if actual_parts > minimum_parts: + return 1 + return 0 + + +def _version_parts(version: str) -> list[int]: + match = re.match(r"v?(\d+(?:\.\d+)*)", version.strip()) + if match is None: + return [0] + return [int(part) for part in match.group(1).split(".")] + + +def _download_configured_asset( + name: str, + command_name: str, + installer: InstallerSpec, + env_overrides: dict[str, str], + current_platform: str, + current_architecture: str, + progress_handler: ProgressHandler | None, +) -> tuple[CommandResult | None, str | None]: + asset = _select_download_asset(installer.download, current_platform, current_architecture) + pseudo_command = ["download", installer.id] + if asset is None: + return ( + CommandResult( + command=pseudo_command, + returncode=1, + stdout="", + stderr=_( + "No download asset configured for platform {platform}/{architecture} in installer {installer}." + ).format( + architecture=current_architecture, + installer=installer.id, + platform=current_platform, + ), + ), + None, + ) + + urls = _download_urls(asset) + if not urls: + return ( + CommandResult( + command=pseudo_command, + returncode=1, + stdout="", + stderr=_("No usable download URL configured for installer {installer}.").format( + installer=installer.id, + ), + ), + None, + ) + + install_dir = _expanded_path(str(installer.download.get("install_dir") or "~/bin")) + installed_name = str(installer.download.get("installed_name") or command_name) + installed_path = install_dir / _installed_executable_name(installed_name, current_platform) + filename = str(asset.get("filename") or installed_path.name) + expected_sha256 = _download_sha256(asset) + timeout_seconds = _download_timeout_seconds(installer.download, asset) + if not expected_sha256: + return ( + CommandResult( + command=pseudo_command, + returncode=1, + stdout="", + stderr=_("download asset {filename} is missing sha256").format(filename=filename), + ), + None, + ) + failures: list[str] = [] + try: + install_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + return ( + CommandResult( + command=pseudo_command, + returncode=1, + stdout="", + stderr=_("failed to create install directory {path}: {error}").format( + error=exc, + path=install_dir, + ), + ), + None, + ) + + for url in urls: + tmp_path = installed_path.with_name(f".{installed_path.name}.download") + try: + result = _download_url( + url, + tmp_path, + expected_sha256, + progress_handler, + name=name, + installer_id=installer.id, + installer_display_key=installer.display_key, + installer_display_name=installer.display_name, + filename=filename, + timeout_seconds=timeout_seconds, + ) + except KeyboardInterrupt: + try: + tmp_path.unlink() + except OSError: + pass + raise + if result is not None: + failures.append(_failure_message(result)) + try: + tmp_path.unlink() + except OSError: + pass + continue + + try: + tmp_path.replace(installed_path) + if current_platform != "windows": + installed_path.chmod(installed_path.stat().st_mode | 0o755) + except OSError as exc: + try: + tmp_path.unlink() + except OSError: + pass + return ( + CommandResult( + command=pseudo_command, + returncode=1, + stdout="", + stderr=_("failed to install {filename} to {path}: {error}").format( + error=exc, + filename=filename, + path=installed_path, + ), + ), + None, + ) + _prepend_path(env_overrides, str(install_dir)) + return None, str(installed_path) + + return ( + CommandResult( + command=pseudo_command, + returncode=1, + stdout="", + stderr="\n".join(failures), + ), + None, + ) + + +def _download_url( + url: str, + target_path: Path, + expected_sha256: str, + progress_handler: ProgressHandler | None, + *, + name: str, + installer_id: str | None, + filename: str, + timeout_seconds: float | None, + installer_display_key: str | None = None, + installer_display_name: str | None = None, +) -> CommandResult | None: + command = ["download", filename] + _emit_progress( + progress_handler, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + phase="download", + status="started", + message=_("Downloading {filename}").format(filename=filename), + command=command, + ) + try: + digest = hashlib.sha256() + downloaded = 0 + last_reported = 0 + with urllib.request.urlopen(url, timeout=timeout_seconds) as response, target_path.open("wb") as output: + total = _content_length(response) + _emit_download_progress( + progress_handler, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + command=command, + filename=filename, + downloaded=downloaded, + total=total, + ) + while True: + chunk = response.read(_DOWNLOAD_CHUNK_SIZE) + if not chunk: + break + output.write(chunk) + digest.update(chunk) + downloaded += len(chunk) + if downloaded - last_reported >= _DOWNLOAD_PROGRESS_INTERVAL: + last_reported = downloaded + _emit_download_progress( + progress_handler, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + command=command, + filename=filename, + downloaded=downloaded, + total=total, + ) + _emit_download_progress( + progress_handler, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + command=command, + filename=filename, + downloaded=downloaded, + total=total, + ) + if total > 0 and downloaded != total: + return CommandResult( + command=command, + returncode=1, + stdout="", + stderr=_("incomplete download for {filename}: expected {expected}, got {actual}").format( + actual=_format_bytes(downloaded), + expected=_format_bytes(total), + filename=filename, + ), + ) + actual_sha256 = digest.hexdigest() + if expected_sha256 and actual_sha256 != expected_sha256: + return CommandResult( + command=command, + returncode=1, + stdout="", + stderr=_("sha256 mismatch for {filename}: expected {expected}, got {actual}").format( + actual=actual_sha256, + expected=expected_sha256, + filename=filename, + ), + ) + except (OSError, urllib.error.URLError, TimeoutError, ValueError, http.client.HTTPException) as exc: + return CommandResult( + command=command, + returncode=1, + stdout="", + stderr=_download_error_message(exc, url), + ) + + _emit_progress( + progress_handler, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + phase="download", + status="succeeded", + message=_("Downloaded {filename}").format(filename=filename), + command=command, + ) + return None + + +def _emit_download_progress( + progress_handler: ProgressHandler | None, + *, + name: str, + installer_id: str | None, + command: list[str], + filename: str, + downloaded: int, + total: int, + installer_display_key: str | None = None, + installer_display_name: str | None = None, +) -> None: + if total > 0: + message = _("Downloading {filename}: {percent} ({downloaded} / {total})").format( + downloaded=_format_bytes(downloaded), + filename=filename, + percent=_format_download_percent(downloaded, total), + total=_format_bytes(total), + ) + total_bytes = total + else: + message = _("Downloading {filename}: {downloaded} downloaded").format( + downloaded=_format_bytes(downloaded), + filename=filename, + ) + total_bytes = None + _emit_progress( + progress_handler, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + phase="download", + status="output", + message=message, + command=command, + downloaded_bytes=downloaded, + total_bytes=total_bytes, + ) + + +def _content_length(response: Any) -> int: + raw_length = response.headers.get("Content-Length") if getattr(response, "headers", None) is not None else None + try: + return int(raw_length or 0) + except (TypeError, ValueError): + return 0 + + +def _select_download_asset( + download: Mapping[str, Any], + current_platform: str, + current_architecture: str, +) -> Mapping[str, Any] | None: + assets = download.get("assets") or [] + for raw_asset in assets: + if not isinstance(raw_asset, Mapping): + continue + platforms = [str(value) for value in raw_asset.get("platforms") or []] + architectures = [_normalize_architecture(str(value)) for value in raw_asset.get("architectures") or []] + if platforms and current_platform not in platforms: + continue + if architectures and current_architecture not in architectures: + continue + return raw_asset + return None + + +def _download_urls(asset: Mapping[str, Any]) -> list[str]: + urls: list[str] = [] + for raw_url in asset.get("urls") or []: + url: str | None = None + if isinstance(raw_url, Mapping): + env_name = raw_url.get("env") + if env_name: + url = os.environ.get(str(env_name)) + else: + configured_url = raw_url.get("url") + url = str(configured_url) if configured_url else None + elif raw_url: + url = str(raw_url) + if not url: + continue + urls.append(os.path.expandvars(os.path.expanduser(url))) + return urls + + +def _download_timeout_seconds(download: Mapping[str, Any], asset: Mapping[str, Any]) -> float | None: + raw_value = asset.get("timeout_seconds") + if raw_value is None: + raw_value = download.get("timeout_seconds") + return _timeout_seconds(raw_value) + + +def _timeout_seconds(raw_value: Any) -> float | None: + if raw_value is None or raw_value == "": + return None + try: + value = float(raw_value) + except (TypeError, ValueError): + return None + return value if value > 0 else None + + +def _probe_timeout_seconds(raw_value: Any) -> float: + return _timeout_seconds(raw_value) or _DEFAULT_PROBE_TIMEOUT_SECONDS + + +def _download_error_message(exc: BaseException, _url: str) -> str: + return _("download failed: {error_type}").format(error_type=type(exc).__name__) + + +def _download_sha256(asset: Mapping[str, Any]) -> str: + raw_sha256 = asset.get("sha256") + value: str | None + if isinstance(raw_sha256, Mapping): + env_name = raw_sha256.get("env") + value = os.environ.get(str(env_name)) if env_name else None + elif raw_sha256: + value = str(raw_sha256) + else: + value = None + return os.path.expandvars(os.path.expanduser(value or "")).strip().lower() + + +def _installed_executable_name(command: str, current_platform: str) -> str: + if current_platform == "windows" and not command.lower().endswith(".exe"): + return f"{command}.exe" + return command + + +def _expanded_path(raw_path: str) -> Path: + return Path(os.path.expandvars(os.path.expanduser(raw_path))) + + +def _expand_env_mapping(raw_env: Mapping[str, str]) -> dict[str, str]: + return {key: os.path.expandvars(os.path.expanduser(value)) for key, value in raw_env.items()} + + +def _is_executable(path: Path) -> bool: + return path.is_file() and (os.access(path, os.X_OK) or os.name == "nt") + + +def _prepend_path(env_overrides: dict[str, str], directory: str) -> None: + existing_path = env_overrides.get("PATH", os.environ.get("PATH", "")) + segments = [segment for segment in existing_path.split(os.pathsep) if segment] + if directory not in segments: + segments.insert(0, directory) + env_overrides["PATH"] = os.pathsep.join(segments) + + +def _call_run_command( + run_command: RunCommand, + command: list[str], + env_overrides: Mapping[str, str], + *, + name: str, + installer_id: str | None, + phase: str, + progress_handler: ProgressHandler | None, + installer_display_key: str | None = None, + installer_display_name: str | None = None, + timeout_seconds: float | None = None, +) -> CommandResult: + env = _environment_with_overrides(env_overrides) if env_overrides else None + on_output: Callable[[str, str], None] | None = None + if progress_handler is not None: + + def on_output(stream: str, text: str) -> None: + if not text: + return + _emit_progress( + progress_handler, + name=name, + installer_id=installer_id, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + phase=phase, + status="output", + message=text, + command=command, + stream=stream, + ) + + kwargs: dict[str, Any] = {} + if _run_command_accepts_keyword_on_output(run_command): + kwargs["on_output"] = on_output + if timeout_seconds is not None and _run_command_accepts_keyword(run_command, "timeout_seconds"): + kwargs["timeout_seconds"] = timeout_seconds + + if kwargs: + try: + return run_command(command, env, **kwargs) + except OSError as exc: + return CommandResult(command=command, returncode=127, stdout="", stderr=str(exc)) + if _run_command_accepts_positional_on_output(run_command): + try: + return run_command(command, env, on_output) + except OSError as exc: + return CommandResult(command=command, returncode=127, stdout="", stderr=str(exc)) + try: + return run_command(command, env) + except OSError as exc: + return CommandResult(command=command, returncode=127, stdout="", stderr=str(exc)) + + +def _run_command_accepts_keyword(run_command: RunCommand, keyword: str) -> bool: + try: + signature = inspect.signature(run_command) + except (TypeError, ValueError): + return True + return keyword in signature.parameters or any( + parameter.kind == parameter.VAR_KEYWORD for parameter in signature.parameters.values() + ) + + +def _run_command_accepts_keyword_on_output(run_command: RunCommand) -> bool: + return _run_command_accepts_keyword(run_command, "on_output") + + +def _run_command_accepts_positional_on_output(run_command: RunCommand) -> bool: + try: + signature = inspect.signature(run_command) + except (TypeError, ValueError): + return True + positional = [ + parameter + for parameter in signature.parameters.values() + if parameter.kind + in { + parameter.POSITIONAL_ONLY, + parameter.POSITIONAL_OR_KEYWORD, + } + ] + return len(positional) >= 3 or any( + parameter.kind == parameter.VAR_POSITIONAL for parameter in signature.parameters.values() + ) + + +def _emit_progress( + progress_handler: ProgressHandler | None, + *, + name: str, + installer_id: str | None, + phase: str, + status: str, + message: str, + installer_display_key: str | None = None, + installer_display_name: str | None = None, + command: list[str] | None = None, + stream: str | None = None, + downloaded_bytes: int | None = None, + total_bytes: int | None = None, +) -> None: + if progress_handler is None: + return + try: + progress_handler( + PrerequisiteProgress( + name=name, + installer_id=installer_id, + phase=phase, + status=status, + message=message, + installer_display_key=installer_display_key, + installer_display_name=installer_display_name, + command=list(command or []), + stream=stream, + downloaded_bytes=downloaded_bytes, + total_bytes=total_bytes, + ) + ) + except Exception: + return + + +def _failure_message(result: CommandResult) -> str: + body_parts = [part.strip() for part in (result.stderr, result.stdout) if part and part.strip()] + body = "\n".join(body_parts) if body_parts else _("exit code {returncode}").format(returncode=result.returncode) + lines = [line.rstrip() for line in body.splitlines() if line.strip()] + if len(lines) > _MAX_FAILURE_MESSAGE_LINES: + lines = ["..."] + lines[-_MAX_FAILURE_MESSAGE_LINES:] + message = _("{command} exited with {returncode}: {body}").format( + command=_format_command(result.command), + returncode=result.returncode, + body="\n".join(lines), + ) + if len(message) > _MAX_FAILURE_MESSAGE_CHARS: + message = "..." + message[-(_MAX_FAILURE_MESSAGE_CHARS - 3) :] + return message + + +def _format_command(command: list[str]) -> str: + return shlex.join(str(part) for part in command) + + +def _format_bytes(size: int) -> str: + value = float(size) + for unit in ("B", "KB", "MB", "GB"): + if value < 1024 or unit == "GB": + if unit == "B": + return f"{int(value)} {unit}" + return f"{value:.1f} {unit}" + value /= 1024 + return f"{int(value)} B" + + +def _format_timeout_seconds(seconds: float) -> str: + value = float(seconds) + if value.is_integer(): + return str(int(value)) + return f"{value:.1f}".rstrip("0").rstrip(".") + + +def _format_download_percent(downloaded: int, total: int) -> str: + if total <= 0: + return "" + percent = max(0.0, min(100.0, downloaded / total * 100)) + if 0 < percent < 1: + return "<1%" + return f"{percent:.0f}%" + + +def _environment_with_overrides(env_overrides: Mapping[str, str]) -> dict[str, str]: + env = dict(os.environ) + env.update(env_overrides) + return env diff --git a/src/iac_code/pipeline/engine/recovery.py b/src/iac_code/pipeline/engine/recovery.py index e5b8f3b4..30807d0f 100644 --- a/src/iac_code/pipeline/engine/recovery.py +++ b/src/iac_code/pipeline/engine/recovery.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging +from pathlib import Path from typing import Any from iac_code.agent.message import Message, ToolResultBlock, ToolUseBlock @@ -10,6 +12,9 @@ record_completion_guard_tool_result, ) from iac_code.pipeline.engine.types import StepResult, StepStatus +from iac_code.tools.result_storage import EXTERNALIZED_RESULT_PATH_METADATA_KEY + +logger = logging.getLogger(__name__) def _tool_uses_by_id(messages: list[Message]) -> dict[str, ToolUseBlock]: @@ -76,7 +81,19 @@ def reconstruct_completion_guard_state(messages: list[Message]) -> dict[str, Any state, tool_name=tool_use.name, tool_input=tool_use.input, - content=block.content, + content=_tool_result_content_for_recovery(block), is_error=block.is_error, ) return state + + +def _tool_result_content_for_recovery(block: ToolResultBlock) -> str: + metadata = block.metadata if isinstance(block.metadata, dict) else {} + raw_path = metadata.get(EXTERNALIZED_RESULT_PATH_METADATA_KEY) + if not isinstance(raw_path, str) or not raw_path: + return block.content + try: + return Path(raw_path).read_text(encoding="utf-8") + except OSError: + logger.warning("Failed to read externalized tool result while rebuilding completion guard state", exc_info=True) + return block.content diff --git a/src/iac_code/pipeline/engine/session.py b/src/iac_code/pipeline/engine/session.py index 790ec040..dd2f3615 100644 --- a/src/iac_code/pipeline/engine/session.py +++ b/src/iac_code/pipeline/engine/session.py @@ -67,6 +67,7 @@ class RestoreResult: execution: dict[str, Any] | None = None attempts: dict[str, Any] | None = None normal_handoff: dict[str, Any] | None = None + prerequisites: dict[str, Any] | None = None class PipelineSession: @@ -97,6 +98,7 @@ def save_running_sync( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self._save_snapshot_sync( "running", @@ -108,6 +110,7 @@ def save_running_sync( execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) async def save_running( @@ -121,6 +124,7 @@ async def save_running( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self.save_running_sync( step_id, @@ -131,6 +135,7 @@ async def save_running( execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) def save_waiting_input_sync( @@ -144,6 +149,7 @@ def save_waiting_input_sync( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self._save_snapshot_sync( "waiting_input", @@ -155,6 +161,7 @@ def save_waiting_input_sync( execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) async def save_waiting_input( @@ -168,6 +175,7 @@ async def save_waiting_input( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self.save_waiting_input_sync( step_id, @@ -178,6 +186,7 @@ async def save_waiting_input( execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) def save_completed_sync( @@ -191,6 +200,7 @@ def save_completed_sync( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self._save_snapshot_sync( "completed", @@ -202,6 +212,7 @@ def save_completed_sync( execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) async def save_completed( @@ -215,6 +226,7 @@ async def save_completed( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self.save_completed_sync( step_id, @@ -225,6 +237,7 @@ async def save_completed( execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) def save_failed_sync( @@ -238,6 +251,7 @@ def save_failed_sync( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self._save_snapshot_sync( "failed", @@ -249,6 +263,7 @@ def save_failed_sync( execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) async def save_failed( @@ -262,6 +277,7 @@ async def save_failed( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self.save_failed_sync( step_id, @@ -272,6 +288,7 @@ async def save_failed( execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) def save_backup_blocked_sync( @@ -332,6 +349,7 @@ def save_user_aborted_sync( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self._save_snapshot_sync( "user_aborted", @@ -343,6 +361,7 @@ def save_user_aborted_sync( execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) async def save_user_aborted( @@ -356,6 +375,7 @@ async def save_user_aborted( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self.save_user_aborted_sync( step_id, @@ -366,6 +386,7 @@ async def save_user_aborted( execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) async def save_step_completion( @@ -377,6 +398,7 @@ async def save_step_completion( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self.save_running_sync( step_id, @@ -386,6 +408,7 @@ async def save_step_completion( execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) async def save_rollback( @@ -400,6 +423,7 @@ async def save_rollback( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self.save_rollback_sync( from_step, @@ -411,6 +435,7 @@ async def save_rollback( execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) def save_rollback_sync( @@ -425,6 +450,7 @@ def save_rollback_sync( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self.session_dir.mkdir(parents=True, exist_ok=True) self.save_running_sync( @@ -436,6 +462,7 @@ def save_rollback_sync( execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) self._append_event( {"type": "rollback", "from": from_step, "to": to_step, "reason": reason, "timestamp": time.time()} @@ -532,6 +559,14 @@ def load_attempts_metadata(self) -> dict[str, Any] | None: attempts = self._load_existing_meta().get("attempts") return attempts if isinstance(attempts, dict) else None + def peek_prerequisites_sync(self) -> dict[str, Any]: + prerequisites = self._load_existing_meta().get("prerequisites") + if not isinstance(prerequisites, dict): + return {} + if not all(isinstance(key, str) for key in prerequisites): + return {} + return dict(prerequisites) + def _append_event(self, event: dict) -> None: with open(self.events_path, "a", encoding="utf-8") as f: f.write(json.dumps(event, ensure_ascii=False) + "\n") @@ -557,6 +592,7 @@ def _save_snapshot_sync( execution: _MetadataValue = _PRESERVE_METADATA, attempts: _MetadataValue = _PRESERVE_METADATA, normal_handoff: _MetadataValue = _PRESERVE_METADATA, + prerequisites: _MetadataValue = _PRESERVE_METADATA, ) -> None: self.session_dir.mkdir(parents=True, exist_ok=True) existing_meta = self._load_existing_meta() @@ -575,6 +611,7 @@ def _save_snapshot_sync( self._preserve_metadata_field(meta, existing_meta, "execution", execution) self._preserve_metadata_field(meta, existing_meta, "attempts", attempts) self._preserve_metadata_field(meta, existing_meta, "normal_handoff", normal_handoff) + self._preserve_metadata_field(meta, existing_meta, "prerequisites", prerequisites) self._atomic_write_yaml(self.context_path, context_snapshot) self._atomic_write_yaml(self.meta_path, meta) @@ -613,7 +650,7 @@ def _restore_result(self, identity: PipelineIdentity | dict | None) -> RestoreRe return self._restore_failure("unknown_status", status=status) if not self._valid_metadata_fields(meta): return self._restore_failure("invalid_meta", status=status) - execution, attempts, normal_handoff = self._restore_metadata_fields(meta) + execution, attempts, normal_handoff, prerequisites = self._restore_metadata_fields(meta) if status in SKIP_RESTORE_STATUSES: return RestoreResult( ok=False, @@ -622,6 +659,7 @@ def _restore_result(self, identity: PipelineIdentity | dict | None) -> RestoreRe execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) identity_data = self._identity_from(identity) if identity is not None else None @@ -663,18 +701,21 @@ def _restore_result(self, identity: PipelineIdentity | dict | None) -> RestoreRe execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) def _restore_metadata_fields( self, meta: dict[str, Any] - ) -> tuple[dict[str, Any] | None, dict[str, Any] | None, dict[str, Any] | None]: + ) -> tuple[dict[str, Any] | None, dict[str, Any] | None, dict[str, Any] | None, dict[str, Any] | None]: execution = meta.get("execution") attempts = meta.get("attempts") normal_handoff = meta.get("normal_handoff") + prerequisites = meta.get("prerequisites") return ( execution if isinstance(execution, dict) else None, attempts if isinstance(attempts, dict) else None, normal_handoff if isinstance(normal_handoff, dict) else None, + prerequisites if isinstance(prerequisites, dict) else None, ) def _restore_failure(self, reason: str | None, status: str | None = None) -> RestoreResult: @@ -766,6 +807,7 @@ def _valid_metadata_fields(self, meta: dict[str, Any]) -> bool: self._valid_execution_metadata(meta.get("execution")) and self._valid_attempts_metadata(meta.get("attempts")) and self._valid_optional_dict_metadata(meta.get("normal_handoff")) + and self._valid_optional_dict_metadata(meta.get("prerequisites")) ) def _valid_optional_dict_metadata(self, value: Any) -> bool: diff --git a/src/iac_code/pipeline/engine/step_executor.py b/src/iac_code/pipeline/engine/step_executor.py index 0c0769b2..a16c7df1 100644 --- a/src/iac_code/pipeline/engine/step_executor.py +++ b/src/iac_code/pipeline/engine/step_executor.py @@ -34,6 +34,7 @@ ) from iac_code.services.session_usage import SessionUsageStore from iac_code.tools.base import ToolRegistry +from iac_code.tools.result_storage import EXTERNALIZED_RESULT_PATH_METADATA_KEY from iac_code.types.stream_events import StreamEvent, ToolResultEvent, ToolUseEndEvent, ToolUseStartEvent logger = logging.getLogger(__name__) @@ -54,6 +55,18 @@ def _content_blocks_text(content: str | list[ContentBlock]) -> str: return "\n".join(parts) +def _completion_guard_tool_result_content(event: ToolResultEvent) -> str: + metadata = event.metadata if isinstance(event.metadata, dict) else {} + raw_path = metadata.get(EXTERNALIZED_RESULT_PATH_METADATA_KEY) + if not isinstance(raw_path, str) or not raw_path: + return event.result + try: + return Path(raw_path).read_text(encoding="utf-8") + except OSError: + logger.warning("Failed to read externalized tool result for completion guard", exc_info=True) + return event.result + + @dataclass class StepAgentLoopContext: """AgentLoop context built by the same path used for step execution.""" @@ -82,6 +95,7 @@ def __init__( memory_content_getter: Callable[[], str] | None = None, auto_trigger_skills: list[Any] | None = None, surface: str = "repl", + tool_context_env_overrides: dict[str, str] | None = None, ) -> None: self._provider_manager = provider_manager self._base_tool_registry = base_tool_registry @@ -95,6 +109,7 @@ def __init__( self._memory_content_getter = memory_content_getter self._auto_trigger_skills = auto_trigger_skills or [] self._surface = surface + self._tool_context_env_overrides = dict(tool_context_env_overrides or {}) self._current_agent_loop = None pipeline_name = getattr(pipeline, "name", "") if not isinstance(pipeline_name, str): @@ -191,7 +206,10 @@ async def consume_complete_step_events( if isinstance(event, ToolUseStartEvent) and event.name == "complete_step": complete_step_ids.add(event.tool_use_id) elif isinstance(event, ToolUseEndEvent): - pending_tool_inputs[event.tool_use_id] = {"tool_name": event.name, "input": dict(event.input)} + pending_tool_inputs[event.tool_use_id] = { + "tool_name": event.name, + "input": copy.deepcopy(event.input), + } if event.tool_use_id in complete_step_ids: pending_complete_input[event.tool_use_id] = event.input elif isinstance(event, ToolResultEvent): @@ -203,8 +221,9 @@ async def consume_complete_step_events( completion_guard_state, tool_name=str(tool_record.get("tool_name") or event.tool_name), tool_input=tool_input, - content=event.result, + content=_completion_guard_tool_result_content(event), is_error=event.is_error, + cwd=self._cwd, ) if event.tool_use_id in complete_step_ids: step_result = (event.metadata or {}).get("step_result") @@ -345,6 +364,8 @@ def build_agent_loop_context( completion_guard_state: dict[str, Any] = ensure_completion_guard_state( reconstruct_completion_guard_state(repaired_messages) ) + if self._cwd: + completion_guard_state["cwd"] = self._cwd if precompleted_tools: completion_guard_state["successful_tools"].update(precompleted_tools.keys()) completion_guard_state["tool_results"].update(precompleted_tools) @@ -435,6 +456,7 @@ def build_agent_loop_context( tool_context_trusted_read_directories=step_skill_roots, tool_context_relative_read_directories=step_skill_roots, pipeline_mode=True, + tool_context_env_overrides=self._tool_context_env_overrides, ) return StepAgentLoopContext( agent_loop=agent_loop, @@ -489,7 +511,12 @@ def _build_full_system_prompt(self, step: StepSpec, context: PipelineContext) -> prompt_file = step.prompt_file_for_surface(self._surface) prompt_path = self._pipeline_dir / prompt_file step_prompt = prompt_path.read_text(encoding="utf-8") if prompt_file else "" - rendered_step_prompt = render_prompt(step_prompt, context, step.context_fields) + rendered_step_prompt = render_prompt( + step_prompt, + context, + step.context_fields, + extra_context={"step_config": step.config}, + ) skill_content = "" if step.skill: @@ -738,6 +765,12 @@ def _build_step_tools( else: if step.tools.include: registry = self._base_tool_registry.filter(step.tools.include) + for name in step.tools.include: + if registry.get(name) is not None: + continue + tool_cls = self._pipeline.pipeline_tools.get(name) + if tool_cls is not None: + registry.register(self._instantiate_pipeline_tool(tool_cls, step)) else: registry = self._base_tool_registry.clone() if step.tools.exclude: @@ -793,7 +826,17 @@ def _register_injectable_tools( elif name == "ros_deploy": registry.register(tool_cls(completion_guard_state=completion_guard_state)) else: - registry.register(tool_cls()) + registry.register(self._instantiate_pipeline_tool(tool_cls, step=None)) + + @staticmethod + def _instantiate_pipeline_tool(tool_cls: type, step: StepSpec | None) -> Any: + try: + parameters = inspect.signature(tool_cls).parameters + except (TypeError, ValueError): + parameters = {} + if step is not None and "step_config" in parameters: + return tool_cls(step_config=step.config) + return tool_cls() @staticmethod def _resolve_include_exclude(config: IncludeExcludeConfig, all_available: list[str]) -> list[str]: diff --git a/src/iac_code/pipeline/engine/step_spec.py b/src/iac_code/pipeline/engine/step_spec.py index 85ded9ff..41b81402 100644 --- a/src/iac_code/pipeline/engine/step_spec.py +++ b/src/iac_code/pipeline/engine/step_spec.py @@ -5,6 +5,7 @@ import json from collections.abc import Callable from dataclasses import dataclass, field +from typing import Any from iac_code.pipeline.engine.context import PipelineContext @@ -24,6 +25,8 @@ class A2AArtifactSpec: path: str content: str media_type: str = "auto" + role: str = "final" + supersedes_path: str | None = None @dataclass(frozen=True) @@ -82,6 +85,7 @@ class StepSpec: exit_condition: dict | None = None a2a_artifacts: list[A2AArtifactSpec] = field(default_factory=list) surface_overrides: dict[str, StepSurfaceOverride] = field(default_factory=dict) + config: dict[str, Any] = field(default_factory=dict) def prompt_file_for_surface(self, surface: str | None) -> str: override = self.surface_overrides.get(surface or "") @@ -136,6 +140,8 @@ class LoadedPipeline: on_complete: OnCompletePolicy | None = None skill_roots: dict[str, str] = field(default_factory=dict) emit_stack_events: bool = False + prerequisites: dict[str, Any] = field(default_factory=dict) + prerequisite_resolution: dict[str, Any] = field(default_factory=dict) def _resolve_dotted(value: object, path: str) -> object: @@ -153,7 +159,13 @@ def _resolve_dotted(value: object, path: str) -> object: return value -def render_prompt(template: str, ctx: PipelineContext, context_fields: list[str]) -> str: +def render_prompt( + template: str, + ctx: PipelineContext, + context_fields: list[str], + *, + extra_context: dict[str, Any] | None = None, +) -> str: """Render a prompt template by replacing {field_name} placeholders with context JSON. Supports dot-notation: {field.sub_key} resolves into the conclusion dict. @@ -163,19 +175,41 @@ def render_prompt(template: str, ctx: PipelineContext, context_fields: list[str] result = template for field_name in context_fields: value = ctx.get_conclusion(field_name) - replacement = json.dumps(value, ensure_ascii=False, indent=2) if value else "{}" - result = result.replace("{" + field_name + "}", replacement) - - if value and isinstance(value, dict): - for key in _collect_dotted_refs(result, field_name): - sub_value = _resolve_dotted(value, key) - if sub_value is None: - sub_replacement = "" - elif isinstance(sub_value, str): - sub_replacement = sub_value - else: - sub_replacement = json.dumps(sub_value, ensure_ascii=False, indent=2) - result = result.replace("{" + field_name + "." + key + "}", sub_replacement) + result = _render_prompt_value(result, field_name, value, empty_object_when_missing=True) + + for field_name, value in (extra_context or {}).items(): + result = _render_prompt_value(result, field_name, value, empty_object_when_missing=False) + return result + + +def _render_prompt_value( + template: str, + field_name: str, + value: object, + *, + empty_object_when_missing: bool, +) -> str: + if empty_object_when_missing and not value: + replacement = "{}" + elif value is None: + replacement = "" + elif isinstance(value, str): + replacement = value + else: + replacement = json.dumps(value, ensure_ascii=False, indent=2) + + result = template.replace("{" + field_name + "}", replacement) + + if isinstance(value, dict): + for key in _collect_dotted_refs(result, field_name): + sub_value = _resolve_dotted(value, key) + if sub_value is None: + sub_replacement = "" + elif isinstance(sub_value, str): + sub_replacement = sub_value + else: + sub_replacement = json.dumps(sub_value, ensure_ascii=False, indent=2) + result = result.replace("{" + field_name + "." + key + "}", sub_replacement) return result diff --git a/src/iac_code/pipeline/engine/sub_pipeline_executor.py b/src/iac_code/pipeline/engine/sub_pipeline_executor.py index d720dad7..8576c1f0 100644 --- a/src/iac_code/pipeline/engine/sub_pipeline_executor.py +++ b/src/iac_code/pipeline/engine/sub_pipeline_executor.py @@ -76,6 +76,7 @@ def __init__( auto_trigger_skills: list[Any] | None = None, surface: str = "repl", backup_service: Any | None = None, + tool_context_env_overrides: dict[str, str] | None = None, ) -> None: self._provider_manager = provider_manager self._base_tool_registry = base_tool_registry @@ -89,6 +90,7 @@ def __init__( self._memory_content_getter = memory_content_getter self._auto_trigger_skills = auto_trigger_skills or [] self._surface = surface + self._tool_context_env_overrides = dict(tool_context_env_overrides or {}) self._active_step_executor = None self._telemetry_correlation: dict[str, str] = {} pipeline_name = getattr(pipeline, "name", "") @@ -168,6 +170,7 @@ async def execute( memory_content_getter=self._memory_content_getter, auto_trigger_skills=self._auto_trigger_skills, surface=self._surface, + tool_context_env_overrides=self._tool_context_env_overrides, ) self._apply_telemetry_correlation(step_executor) @@ -186,6 +189,7 @@ async def execute( ) conclusions: dict[str, Any] = {} + step_conclusions: dict[str, Any] = {} try: while not state_machine.is_complete: @@ -251,6 +255,7 @@ async def execute( # Accumulate conclusions if step_result.conclusion: + step_conclusions[step.step_id] = step_result.conclusion conclusions[step.conclusion_field] = step_result.conclusion if event_callback: @@ -272,7 +277,9 @@ async def execute( target, reason = step_result.rollback_request try: state_machine.rollback(target, reason) - conclusions = self._conclusions_before_step(sub_spec, target, conclusions) + step_conclusions = self._step_conclusions_before_step(sub_spec, target, step_conclusions) + conclusions = self._field_conclusions_from_step_conclusions(sub_spec, step_conclusions) + self._restore_conclusions_to_context(sub_context, conclusions) self._mark_rolled_back_fields_stale(sub_context, sub_spec, target) except ValueError as e: valid_targets = [s.step_id for s in sub_spec.steps] @@ -345,6 +352,7 @@ def _make_step_executor(self) -> StepExecutor: memory_content_getter=self._memory_content_getter, auto_trigger_skills=self._auto_trigger_skills, surface=self._surface, + tool_context_env_overrides=self._tool_context_env_overrides, ) self._apply_telemetry_correlation(executor) return executor @@ -359,6 +367,7 @@ async def execute_streaming( *, start_from_step: str | None = None, preserved_conclusions: dict[str, Any] | None = None, + preserved_step_conclusions: dict[str, Any] | None = None, user_message: str | list[ContentBlock] | None = None, resume_messages: list[Message] | None = None, parent_step_id: str | None = None, @@ -374,16 +383,26 @@ async def execute_streaming( if resume_state and resume_state.get("sub_pipeline_id") else f"{sub_spec.name}_{uuid.uuid4().hex[:8]}" ) + sub_context = self._build_sub_context(sub_spec, candidate, parent_context) if resume_state and isinstance(resume_state.get("context"), dict): + merged_context_snapshot = sub_context.to_snapshot() + merged_context_snapshot.update(resume_state["context"]) sub_context = PipelineContext.from_snapshot( - resume_state["context"], + merged_context_snapshot, self._sub_context_dependencies(sub_spec), ) - else: - sub_context = self._build_sub_context(sub_spec, candidate, parent_context) - if preserved_conclusions and not resume_state: - for field_name, value in preserved_conclusions.items(): + replay_conclusions: dict[str, Any] = {} + if preserved_step_conclusions: + replay_conclusions.update( + self._field_conclusions_from_step_conclusions(sub_spec, preserved_step_conclusions) + ) + if preserved_conclusions: + replay_conclusions.update(preserved_conclusions) + for field_name, value in replay_conclusions.items(): + try: sub_context.set_conclusion(field_name, value) + except KeyError: + continue if resume_state and isinstance(resume_state.get("state_machine"), dict): state_machine = StateMachine.from_snapshot( resume_state["state_machine"], @@ -428,14 +447,22 @@ def sub_step_event_data(step_id: str, extra: dict[str, Any] | None = None) -> di data=sub_pipeline_event_data(), ) - if resume_state and isinstance(resume_state.get("conclusions"), dict): + if resume_state and isinstance(resume_state.get("step_conclusions"), dict): + step_conclusions = dict(resume_state["step_conclusions"]) + conclusions = self._field_conclusions_from_step_conclusions(sub_spec, step_conclusions) + elif preserved_step_conclusions: + step_conclusions = dict(preserved_step_conclusions) + conclusions = self._field_conclusions_from_step_conclusions(sub_spec, step_conclusions) + elif resume_state and isinstance(resume_state.get("conclusions"), dict): conclusions = dict(resume_state["conclusions"]) + step_conclusions = self._infer_legacy_step_conclusions(sub_spec, conclusions) else: conclusions: dict[str, Any] = {} for existing_step in sub_spec.steps: field = sub_context.get_field(existing_step.conclusion_field) if field.value is not None and not field.stale: conclusions[existing_step.conclusion_field] = field.value + step_conclusions = self._infer_legacy_step_conclusions(sub_spec, conclusions) is_first_step = True terminal_event: PipelineEvent | None = None current_step_attrs: dict[str, Any] | None = None @@ -468,6 +495,7 @@ async def publish_sub_step_state( "active_attempt_id": attempt_info.get("attempt_id"), "transcript_id": attempt_info.get("transcript_id"), "conclusions": dict(conclusions), + "step_conclusions": dict(step_conclusions), } result = sub_step_state_callback(payload) if asyncio.iscoroutine(result): @@ -700,13 +728,23 @@ def sub_step_attrs_for_current(step, step_index: int) -> dict[str, Any]: emit_sub_pipeline_failed(error_summary, "StepFailed", failure.error_id) break + step_conclusions[step.step_id] = step_result.conclusion conclusions[step.conclusion_field] = step_result.conclusion if step_result.rollback_request: target, reason = step_result.rollback_request try: state_machine.rollback(target, reason) - conclusions = self._conclusions_before_step(sub_spec, target, conclusions) + step_conclusions = self._step_conclusions_before_step( + sub_spec, + target, + step_conclusions, + ) + conclusions = self._field_conclusions_from_step_conclusions( + sub_spec, + step_conclusions, + ) + self._restore_conclusions_to_context(sub_context, conclusions) self._observability.sub_step_completed( duration_ms=self._observability.duration_ms(sub_step_started_at), **current_step_attrs, @@ -929,6 +967,7 @@ def sub_step_attrs_for_current(step, step_index: int) -> dict[str, Any]: { "failed": False, "conclusions": conclusions, + "step_conclusions": step_conclusions, }, ), ) @@ -972,14 +1011,26 @@ def _conclusions_before_step( sub_spec: SubPipelineSpec, target_step_id: str, conclusions: dict[str, Any], + step_conclusions: dict[str, Any] | None = None, ) -> dict[str, Any]: """Keep only conclusions produced before a rollback target.""" + if step_conclusions is not None: + return self._field_conclusions_from_step_conclusions( + sub_spec, + self._step_conclusions_before_step(sub_spec, target_step_id, step_conclusions), + ) try: - target_index = [step.step_id for step in sub_spec.steps].index(target_step_id) + target_index = self._step_index(sub_spec, target_step_id) except ValueError: return dict(conclusions) - preserved_fields = {step.conclusion_field for step in sub_spec.steps[:target_index]} - return {field: value for field, value in conclusions.items() if field in preserved_fields} + result: dict[str, Any] = {} + for conclusion_field, value in conclusions.items(): + writer_indices = [ + idx for idx, step in enumerate(sub_spec.steps) if step.conclusion_field == conclusion_field + ] + if writer_indices and max(writer_indices) < target_index: + result[conclusion_field] = value + return result def _mark_rolled_back_fields_stale( self, @@ -989,8 +1040,56 @@ def _mark_rolled_back_fields_stale( ) -> None: """Mark the rollback target and later sub-step fields stale in the persisted sub-context.""" try: - target_index = [step.step_id for step in sub_spec.steps].index(target_step_id) + target_index = self._step_index(sub_spec, target_step_id) except ValueError: return for step in sub_spec.steps[target_index:]: context.mark_stale(step.conclusion_field) + + @staticmethod + def _step_index(sub_spec: SubPipelineSpec, step_id: str) -> int: + return [step.step_id for step in sub_spec.steps].index(step_id) + + def _step_conclusions_before_step( + self, + sub_spec: SubPipelineSpec, + target_step_id: str, + step_conclusions: dict[str, Any], + ) -> dict[str, Any]: + target_index = self._step_index(sub_spec, target_step_id) + return { + step.step_id: step_conclusions[step.step_id] + for step in sub_spec.steps[:target_index] + if step.step_id in step_conclusions + } + + @staticmethod + def _field_conclusions_from_step_conclusions( + sub_spec: SubPipelineSpec, + step_conclusions: dict[str, Any], + ) -> dict[str, Any]: + conclusions: dict[str, Any] = {} + for step in sub_spec.steps: + if step.step_id in step_conclusions: + conclusions[step.conclusion_field] = step_conclusions[step.step_id] + return conclusions + + @staticmethod + def _infer_legacy_step_conclusions( + sub_spec: SubPipelineSpec, + conclusions: dict[str, Any], + ) -> dict[str, Any]: + step_conclusions: dict[str, Any] = {} + for conclusion_field, value in conclusions.items(): + writers = [step for step in sub_spec.steps if step.conclusion_field == conclusion_field] + if len(writers) == 1: + step_conclusions[writers[0].step_id] = value + return step_conclusions + + @staticmethod + def _restore_conclusions_to_context(context: PipelineContext, conclusions: dict[str, Any]) -> None: + for field_name, value in conclusions.items(): + try: + context.set_conclusion(field_name, value) + except KeyError: + continue diff --git a/src/iac_code/pipeline/selling/pipeline.yaml b/src/iac_code/pipeline/selling/pipeline.yaml index a4180021..2dd6fe29 100644 --- a/src/iac_code/pipeline/selling/pipeline.yaml +++ b/src/iac_code/pipeline/selling/pipeline.yaml @@ -38,6 +38,81 @@ feature_flags: enable_reviewing: default: false env: IAC_CODE_PIPELINE_SELLING_ENABLE_REVIEWING + display_key: review_step + +prerequisites: + infraguard: + command: infraguard + required_by_flags: [enable_reviewing] + on_missing: + repl: prompt_install + non_interactive: disable_feature + version_check: + command: [infraguard, version] + minimum: "0.10.1" + pattern: 'InfraGuard:\s*(?P\d+\.\d+\.\d+)' + timeout_seconds: 30 + installers: + - id: direct-binary + display_key: direct_binary_download + platforms: [darwin, linux, windows] + download: + install_dir: ~/bin + installed_name: infraguard + timeout_seconds: 1800 + assets: + - platforms: [darwin] + architectures: [arm64] + filename: infraguard-v0.10.1-darwin-arm64 + sha256: cda2ba2eab1076a5f8b6c66654295a9b9aa8e9b302407e9580a4d75b7872b76f + urls: + - env: IAC_CODE_INFRAGUARD_DARWIN_ARM64_URL + - https://ros-public-tools.oss-cn-beijing.aliyuncs.com/github-releases/aliyun/infraguard/0.10.1/infraguard-v0.10.1-darwin-arm64 + - https://github.com/aliyun/infraguard/releases/download/v0.10.1/infraguard-v0.10.1-darwin-arm64 + - platforms: [darwin] + architectures: [amd64] + filename: infraguard-v0.10.1-darwin-amd64 + sha256: d9e8963250de8a13bbe4a6e9e528ad638f6b8fb4ad6928e2bfc27771a1db3260 + urls: + - env: IAC_CODE_INFRAGUARD_DARWIN_AMD64_URL + - https://ros-public-tools.oss-cn-beijing.aliyuncs.com/github-releases/aliyun/infraguard/0.10.1/infraguard-v0.10.1-darwin-amd64 + - https://github.com/aliyun/infraguard/releases/download/v0.10.1/infraguard-v0.10.1-darwin-amd64 + - platforms: [linux] + architectures: [amd64] + filename: infraguard-v0.10.1-linux-amd64 + sha256: a0f66d5390df1746b10bdd0e87dfe68b6418c22d43add22a4ca9cc99f22ef66b + urls: + - env: IAC_CODE_INFRAGUARD_LINUX_AMD64_URL + - https://ros-public-tools.oss-cn-beijing.aliyuncs.com/github-releases/aliyun/infraguard/0.10.1/infraguard-v0.10.1-linux-amd64 + - https://github.com/aliyun/infraguard/releases/download/v0.10.1/infraguard-v0.10.1-linux-amd64 + - platforms: [linux] + architectures: [arm64] + filename: infraguard-v0.10.1-linux-arm64 + sha256: 5d929b89ff6ef5e8d6cd95ce3d84cb1747452f055706037b972463126065e262 + urls: + - env: IAC_CODE_INFRAGUARD_LINUX_ARM64_URL + - https://ros-public-tools.oss-cn-beijing.aliyuncs.com/github-releases/aliyun/infraguard/0.10.1/infraguard-v0.10.1-linux-arm64 + - https://github.com/aliyun/infraguard/releases/download/v0.10.1/infraguard-v0.10.1-linux-arm64 + - platforms: [windows] + architectures: [amd64] + filename: infraguard-v0.10.1-windows-amd64.exe + sha256: 48dd98cec9cc825fd273280e3ca8502fc65ab31170394b18d6e388ccb4c80332 + urls: + - env: IAC_CODE_INFRAGUARD_WINDOWS_AMD64_URL + - https://ros-public-tools.oss-cn-beijing.aliyuncs.com/github-releases/aliyun/infraguard/0.10.1/infraguard-v0.10.1-windows-amd64.exe + - https://github.com/aliyun/infraguard/releases/download/v0.10.1/infraguard-v0.10.1-windows-amd64.exe + - platforms: [windows] + architectures: [arm64] + filename: infraguard-v0.10.1-windows-arm64.exe + sha256: 7ab067e0af3e59173bf04d8a2624a2cf6b42eda0fe578bc0be2540394d49636e + urls: + - env: IAC_CODE_INFRAGUARD_WINDOWS_ARM64_URL + - https://ros-public-tools.oss-cn-beijing.aliyuncs.com/github-releases/aliyun/infraguard/0.10.1/infraguard-v0.10.1-windows-arm64.exe + - https://github.com/aliyun/infraguard/releases/download/v0.10.1/infraguard-v0.10.1-windows-arm64.exe + post_install: + timeout_seconds: 300 + commands: + - [infraguard, policy, update] context_dependencies: intent: [] @@ -67,6 +142,7 @@ sub_pipelines: - path: conclusion.file_path content: conclusion.template media_type: auto + supersedes_path: conclusion.file_path forward: reviewing skill: iac-aliyun-template-generating prompt: prompts/template_generating.md @@ -79,23 +155,184 @@ sub_pipelines: - id: reviewing description: "审查模板的安全性和最佳实践" enabled_when: enable_reviewing - conclusion_field: review + conclusion_field: template forward: cost_estimating skill: iac-aliyun-review prompt: prompts/reviewing.md - context_fields: [template] + context_fields: [intent, candidate, template] + a2a_artifacts: + - path: conclusion.file_path + content: conclusion.template + media_type: auto + role: final + supersedes_path: conclusion.file_path + config: + infraguard: + mode: static + aspects: + security: + name: "安全性" + selection_guidance: "包含公网入口、安全组、访问控制、身份权限或任何可被外部访问的资源时选择;安全基线通常默认选择。" + policies: + - pack:aliyun:security + high_availability: + name: "高可用" + selection_guidance: "用户要求生产级、容灾、多可用区、SLA,或 candidate 明确设计为高可用方案时选择;低成本单机方案通常跳过。" + policies: + - pack:aliyun:high-availability + cost_optimization: + name: "成本优化" + selection_guidance: "用户有预算约束、低成本偏好,或方案包含可能闲置/过期的资源时选择。" + policies: + - pack:aliyun:cost-optimization + compliance: + name: "合规性" + selection_guidance: "用户提出行业合规、等保、审计、企业治理或监管要求时选择;普通实验/个人项目通常跳过。" + policies: + - pack:aliyun:compliance + best_practice: + name: "最佳实践" + selection_guidance: "所有可部署 ROS 模板的通用质量基线,通常默认选择。" + policies: + - pack:aliyun:best-practice + operations: + name: "可运维性" + selection_guidance: "生产环境、长期运行服务,或需要变更管理、备份、稳定性保障时选择。" + policies: + - pack:aliyun:operations + network_architecture: + name: "网络架构" + selection_guidance: "模板包含 VPC、VSwitch、SLB/ALB、EIP、公网访问、跨资源网络路径或网络隔离要求时选择。" + policies: + - pack:aliyun:network-architecture + elasticity: + name: "弹性能力" + selection_guidance: "用户提到突发流量、秒杀、自动扩缩容,或方案包含弹性伸缩/负载均衡/动态容量设计时选择。" + policies: + - pack:aliyun:elasticity + ignore_waivers: true + max_fix_rounds: 5 + blocking_severities: [critical, high] + completion_guards: + - when_conclusion_field_equals: + validated: true + when_tool_result_exists: + tools: [write_file, edit_file] + match_conclusion_field: file_path + match_result_field: result.file_path + require_tool_result: + tool: ros_validate_template + match_conclusion_field: file_path + match_result_field: input.template_url + disallow_tool_results_after_match: + - tools: [write_file, edit_file] + match_conclusion_field: file_path + match_result_field: result.file_path + message_key: reviewing_rerun_after_validate_template_write + message_key: reviewing_validate_template_required + - when_conclusion_field_equals: + review_passed: true + require_tool_result: + tool: infraguard_scan + latest_match: true + match_conclusion_field: file_path + match_result_field: file_path + match_fields: + - conclusion_field: template_sha256 + result_field: file_sha256 + result_field_equals: + passed: true + blocking_findings: 0 + required_result_fields: [file_content, file_sha256, selected_aspects, expanded_policies] + disallow_tool_results_after_match: + - tools: [write_file, edit_file] + match_conclusion_field: file_path + match_result_field: result.file_path + message_key: reviewing_rerun_after_final_infraguard_write + require_conclusion_sha256: + content_field: template + sha256_field: template_sha256 + message_key: reviewing_final_infraguard_required + - when_conclusion_field_equals: + review_passed: true + when_tool_result_exists: + tools: [write_file, edit_file] + match_conclusion_field: file_path + match_result_field: result.file_path + require_tool_result: + tool: infraguard_scan + latest_match: true + after_tool_result: + tool: ros_validate_template + match_conclusion_field: file_path + match_result_field: input.template_url + match_conclusion_field: file_path + match_result_field: file_path + match_fields: + - conclusion_field: template_sha256 + result_field: file_sha256 + result_field_equals: + passed: true + blocking_findings: 0 + required_result_fields: [file_content, file_sha256, selected_aspects, expanded_policies] + disallow_tool_results_after_match: + - tools: [write_file, edit_file] + match_conclusion_field: file_path + match_result_field: result.file_path + message_key: reviewing_rerun_after_final_infraguard_write + message_key: reviewing_final_infraguard_required + conclusion_schema: + type: object + required: + - template + - template_sha256 + - file_path + - region + - description + - validated + - review_passed + - review_issues + - selected_review_aspects + - skipped_review_aspects + - resolved_infraguard_policies + - infraguard_summary + - fix_summary + additionalProperties: false + properties: + template: + type: string + template_sha256: + type: string + file_path: + type: string + region: + type: string + description: + type: string + validated: + const: true + review_passed: + const: true + review_issues: + type: array + selected_review_aspects: + type: array + skipped_review_aspects: + type: array + resolved_infraguard_policies: + type: array + infraguard_summary: + type: object + fix_summary: + type: string tools: - include: [ros_validate_template] - exclude: [] + include: [read_file, write_file, edit_file, infraguard_scan, ros_validate_template, aliyun_doc_search] + exclude: [write_memory] inject_tools: [ros_validate_template] - id: cost_estimating description: "评估模板的资源成本" conclusion_field: cost - a2a_artifacts: - - path: conclusion.file_path - content: conclusion.template - media_type: auto forward: null skill: iac-aliyun-cost prompt: prompts/cost_estimating.md @@ -135,7 +372,7 @@ steps: copy_tool_result_to_conclusion: selected_id: clarification_choice free_text: clarification_text - message: "这个输入仍缺少明确的云资源、部署目标或运维约束,需要先向用户澄清。" + message_key: intent_cloud_resource_clarification_required - require_tool: ask_user_question when_user_message_matches_any: - "(AWS|Amazon Web Services|Azure|GCP|Google Cloud|GoogleCloud|腾讯云|华为云|火山引擎|UCloud|EC2|S3|Lambda|CloudFormation|EKS|AKS|GKE)" @@ -143,7 +380,7 @@ steps: copy_tool_result_to_conclusion: selected_id: clarification_choice free_text: clarification_text - message: "当前流程只支持阿里云部署需求;非阿里云需求需要先让用户改成阿里云目标或确认暂不处理。" + message_key: intent_alibaba_cloud_only - require_tool: ask_user_question when_conclusion_field_equals: confidence: low @@ -151,7 +388,7 @@ steps: copy_tool_result_to_conclusion: selected_id: clarification_choice free_text: clarification_text - message: "low 置信度意图不能直接完成,需要先向用户澄清。" + message_key: intent_low_confidence_clarification_required - require_tool: ask_user_question when_conclusion_field_equals: category: chat @@ -159,7 +396,7 @@ steps: copy_tool_result_to_conclusion: selected_id: clarification_choice free_text: clarification_text - message: "这个输入不是部署/云资源需求,需要先让用户重新输入部署目标或确认暂不处理。" + message_key: intent_not_deployment_request - require_tool: ask_user_question when_conclusion_field_equals: category: code_request @@ -167,7 +404,7 @@ steps: copy_tool_result_to_conclusion: selected_id: clarification_choice free_text: clarification_text - message: "这个输入不是部署/云资源需求,需要先让用户重新输入部署目标或确认暂不处理。" + message_key: intent_not_deployment_request - require_tool: ask_user_question when_conclusion_field_equals: category: knowledge_question @@ -175,7 +412,7 @@ steps: copy_tool_result_to_conclusion: selected_id: clarification_choice free_text: clarification_text - message: "这个输入不是部署/云资源需求,需要先让用户重新输入部署目标或确认暂不处理。" + message_key: intent_not_deployment_request base_prompt_sections: include: [identity, system, env, cloud_config, runtime_context, output_style] exclude: [] @@ -269,7 +506,7 @@ steps: is_success: true status_in: [CREATE_COMPLETE] match_conclusion_field: stack_id - message: "部署成功必须等待 ros_deploy 返回 CREATE_COMPLETE。" + message_key: deploy_wait_create_complete tools: include: [] exclude: [write_memory, ros_stack] diff --git a/src/iac_code/pipeline/selling/prompts/reviewing.md b/src/iac_code/pipeline/selling/prompts/reviewing.md index a08497da..00904498 100644 --- a/src/iac_code/pipeline/selling/prompts/reviewing.md +++ b/src/iac_code/pipeline/selling/prompts/reviewing.md @@ -1,19 +1,63 @@ -# 步骤:审查 +# 步骤:审查并修复 -你正在执行 AI 售卖流程的审查步骤:对生成的模板进行安全与最佳实践审查。 +你正在执行 AI 售卖流程的模板审查步骤。此步骤用 InfraGuard 审查上一步生成的 ROS 模板;如果初始扫描干净,不做额外校验和复扫,直接把模板继续作为 `template` 结论传给成本预估;如果发现需要修复的问题,则直接修复原模板文件。 -## 生成的模板(上一步结论) +## 模板信息 +- 文件路径:`{template.file_path}` +- 地域:`{template.region}` +- 描述:`{template.description}` + +## 用户意图 ```json -{template} +{intent} ``` -## 行为规则 -- **发现 high 级问题时,必须通过 rollback_request 回溯修复**,不能只报告问题 - - 所有模板问题(语法/引用/安全组/规格配置) → rollback 到 `template_generating` -- 无 high 级问题时,正常提交 `conclusion.passed = true` +## 当前候选方案 +```json +{candidate} +``` -## 输出 -调用 `complete_step` 提交审查结论。发现 high 级问题时必须同时填写 `rollback_request`。 +## InfraGuard step config +以下值由 `pipeline.yaml` 的当前步骤配置动态渲染。`aspects` 是可选审查维度目录,每个 aspect 下的 `policies` 由工具展开为 InfraGuard policy: -## 注意事项 -- 不要读取项目文件或记忆,所需的上下文已在上方提供。 +```json +{step_config.infraguard} +``` + +## 执行流程 +1. 用 `read_file` 读取 `template.file_path` 指向的原模板文件。 +2. 根据用户意图、当前候选方案和模板资源,选择适用的 aspect key。只能从 step config 的 `aspects` key 中选择;不要自己编写 policy id。记录 `selected_aspects` 和跳过 aspect 的原因。 +3. 调用 `infraguard_scan`,参数来自上方 step config:`file_path={template.file_path}`,`mode`、`selected_aspects`、`aspect_policy_map=aspects`、`ignore_waivers`、`blocking_severities` 均使用渲染出的配置值,并设置 `include_file_content=true`。 +4. 如果初始扫描 `passed=true`、`blocking_findings=0`,且没有需要修复的 finding,不要调用 `ros_validate_template`,不要再次调用 `infraguard_scan`;直接把这次扫描返回的 `file_content` 原样放入 `complete_step.conclusion.template`,把 `file_sha256` 放入 `complete_step.conclusion.template_sha256`。 +5. 如果 InfraGuard findings 需要修改模板,直接修改原模板文件,使用 `edit_file` 或 `write_file` 写回同一个 `template.file_path`。 +6. 只要本步骤修改过 `template.file_path`,就必须调用 `ros_validate_template(template_url=template.file_path)` 校验同一个文件路径。 +7. 如 `ros_validate_template` 失败,分析错误并继续修复原模板文件;最多执行 `max_fix_rounds` 轮修复。 +8. `ros_validate_template` 通过后,使用同一组 `selected_aspects` 再次调用 `infraguard_scan` 扫描同一个 `template.file_path`,并设置 `include_file_content=true`。 +9. 如果本步骤没有改动模板,初始扫描就是最新可用扫描;如果改动过模板,最终扫描才是最新可用扫描。只有最新可用 InfraGuard 结果 `passed=true`、`blocking_findings=0`,且 `conclusion.template` 的 sha256 精确等于该扫描的 `file_sha256` 时,才能调用 `complete_step`。 + +如果任一轮 `infraguard_scan` 返回工具错误或错误 payload(如 `command_not_found`、`timeout`、`malformed_json`、`unexpected_exit_code`、`unknown_policy_aspect`),不要执行 policy update,不要跳过扫描继续完成,也不要输出 `validated=true` 或 `review_passed=true`。记录错误类型、`command`、`stderr` 摘要、`file_path`、`selected_aspects` 和已展开 policies(如果有),让 pipeline/prerequisites 或用户能处理该前置问题。 + +## 禁止事项 +- 不要执行 InfraGuard 的 policy update 命令;策略更新由 pipeline prerequisites 的 post_install 负责。 +- 不要默认使用 rollback_request 回到 `template_generating`;本步骤负责直接修复原模板文件。 +- 不要把问题只放进单独的 `review` 字段;本步骤的 `conclusion_field` 是 `template`。 +- 不要只报告 InfraGuard 标记为 blocking 的问题而不修复。 +- 不要改写成新的文件路径;后续步骤必须继续读取同一个 `template.file_path`。 +- 不要在没有 InfraGuard finding、`ros_validate_template` 错误或用户明确约束的情况下,按硬编码安全/合规/架构规则改写模板。 +- 初始 InfraGuard 扫描已经通过且未修改模板时,不要为了“确认”而调用 `ros_validate_template` 或第二次 `infraguard_scan`。 + +## 输出 +调用 `complete_step`,`conclusion` 必须包含: +- `template`: 最新文件内容,必须原样等于最新可用 `infraguard_scan` 返回的 `file_content` +- `template_sha256`: 最新模板内容的 sha256,必须等于最新可用 `infraguard_scan` 返回的 `file_sha256` +- `file_path`: 与输入相同的 `template.file_path` +- `region`: 与输入相同的地域 +- `description`: 修复后模板说明 +- `validated`: `true` +- `review_passed`: `true` +- `review_issues`: InfraGuard 与 `ros_validate_template` 发现和修复过的问题列表;如果没有问题,填空数组 +- `selected_review_aspects`: 已选择的 aspect key、名称和选择原因 +- `skipped_review_aspects`: 未选择的 aspect key、名称和跳过原因 +- `resolved_infraguard_policies`: 最新可用 `infraguard_scan` 返回的 `expanded_policies` +- `infraguard_summary`: 最新可用 InfraGuard 扫描摘要,包含 `passed`、`blocking_findings`、`findings` 等关键信息 +- `fix_summary`: 修复动作摘要 diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-review/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-review/SKILL.md index 5753cb5a..40b962cd 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-review/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-review/SKILL.md @@ -1,76 +1,140 @@ --- name: iac-aliyun-review -description: 模板安全、最佳实践和合规审查,发现问题必须回溯修复 +description: 使用 InfraGuard 审查 ROS 模板;有发现时直接修复并验证,初始扫描干净时直接输出模板 +when_to_use: 当需要对售卖 pipeline 中生成的阿里云 ROS 模板进行合规、安全和最佳实践修复时 +user_invocable: false conclusion_schema: type: object - required: [passed, issues, summary] + required: [template, template_sha256, file_path, region, description, validated, review_passed, review_issues, selected_review_aspects, skipped_review_aspects, resolved_infraguard_policies, infraguard_summary, fix_summary] additionalProperties: false properties: - passed: - type: boolean - description: 审查是否通过(无 high 级问题) - issues: + template: + type: string + template_sha256: + type: string + file_path: + type: string + region: + type: string + description: + type: string + validated: + const: true + review_passed: + const: true + review_issues: + type: array + selected_review_aspects: type: array - items: - type: object - required: [severity, category, description, suggestion] - properties: - severity: - type: string - enum: [high, medium, low] - category: - type: string - enum: [security, ha, practice, correctness] - description: - type: string - suggestion: - type: string - summary: + skipped_review_aspects: + type: array + resolved_infraguard_policies: + type: array + infraguard_summary: + type: object + fix_summary: type: string - description: 审查摘要 --- -# 模板审查 +# ROS 模板审查与修复 + +本技能用于售卖 pipeline 的 `reviewing` 步骤。它不是单独生成审查报告,而是用 InfraGuard 扫描 ROS 模板:如果初始扫描已经通过且不需要修改模板,直接用该扫描结果完成;如果发现需要修复的问题,则直接修复原模板文件,通过 `ros_validate_template`,再执行最终 InfraGuard 扫描,最后用修复后的模板覆盖当前 `template` 结论。 + +## 输入与边界 + +- 从上下文读取 `intent`、`candidate`、`template.file_path`、`template.region`、`template.description` 和 `template.template`。 +- 必须先用 `read_file` 读取 `template.file_path` 指向的原模板文件,以文件内容为准。 +- InfraGuard 安装、可执行文件查找和策略更新由 pipeline prerequisites 管理;不要执行 InfraGuard 的 policy update 命令。 +- 不要在技能中维护或复制 InfraGuard 官方策略目录、策略包或 Rego package bodies;策略来源以 InfraGuard CLI 当前策略库为准。 +- 不要默认使用 rollback_request 回到 `template_generating`;本步骤负责直接修复当前模板。 +- 不要把问题只放进单独的 `review` 字段;本步骤的结论字段是 `template`,必须输出修复后的模板内容和同一个文件路径。 -对生成的 IaC 模板进行安全和最佳实践审查。**发现问题时必须通过 rollback_request 回溯修复,不能只报告问题。** +## Aspect 选择 -## 审查维度 +`config.infraguard.aspects` 定义了可选审查维度和每个维度对应的 InfraGuard policies。你只能选择这些已配置的 aspect key,不要自己编写或猜测 policy id。 -1. **安全性** - - 安全组是否限制了不必要的入站端口(0.0.0.0/0 开放非 80/443 端口 = 高危) - - 是否启用了加密(磁盘、传输、存储) - - 是否暴露了不必要的公网 IP - - RAM 权限是否最小化 +根据 `intent`、`candidate` 和模板资源判断适用维度: -2. **高可用** - - 关键资源是否跨可用区 - - 是否配置了健康检查 - - 数据库是否启用了备份 +- 安全性和通用最佳实践通常适用于所有可部署模板。 +- 高可用只在用户或候选方案要求生产级、多可用区、容灾或 SLA 时选择;低成本单机方案通常跳过。 +- 弹性能力只在用户提到突发流量、秒杀、自动扩缩容,或候选方案包含弹性伸缩/动态容量设计时选择。 +- 合规性只在用户提出行业合规、等保、审计、企业治理或监管要求时选择。 +- 网络架构在模板包含 VPC、VSwitch、SLB/ALB、EIP、公网访问或网络隔离要求时选择。 +- 模板被本步骤修改后必须检查正确性:资源依赖(DependsOn)、引用的可用区和地域、ROS 模板语法与 schema 都需要通过 `ros_validate_template` 验证。 -3. **最佳实践** - - 资源命名是否规范(使用 Stack 名称前缀) - - 是否添加了标签(至少 Environment、Project) - - Parameters 是否有合理 AllowedValues 约束 - - Outputs 中 Fn::GetAtt 引用的属性是否存在 +记录选择结果: -4. **模板正确性** - - 资源依赖是否正确(DependsOn) - - 引用的可用区是否存在于目标地域 - - 模板语法是否合法(可通过 `ros_validate_template` 校验) +- `selected_aspects`: 传给 `infraguard_scan` 的 aspect key 数组。 +- `selected_review_aspects`: conclusion 中记录每个已选 aspect 的 key、名称和选择原因。 +- `skipped_review_aspects`: conclusion 中记录每个未选 aspect 的 key、名称和跳过原因。 +- `resolved_infraguard_policies`: conclusion 中记录最新可用扫描工具返回的 `expanded_policies`。 -## 行为规则 +## InfraGuard 扫描 -- **通过审查**(无 high 级问题):调用 `complete_step`,`conclusion.passed = true` -- **未通过审查**(发现 high 级问题):**必须**在 `complete_step` 中填写 `rollback_request`: - - 所有模板问题(语法/引用/安全组/规格配置) → 回溯到 `template_generating` -- **不要**只报告问题而不回溯修复 +调用 `infraguard_scan`,参数来自当前 step config 中的 `config.infraguard`: -## 可选工具使用 +- `file_path`: `template.file_path` +- `mode`: `mode` +- `selected_aspects`: 基于 `intent`、`candidate` 和模板资源选择出的 aspect key 数组 +- `aspect_policy_map`: `aspects` +- `ignore_waivers`: `ignore_waivers` +- `blocking_severities`: `blocking_severities` -- 可调用 `ros_validate_template` 校验模板语法 -- 不需要调用其他工具 +解释 findings 时,优先提取资源名、属性路径、severity、规则说明和修复建议。`high`、`critical` 或出现在 `blocking_severities` 中的 finding 都必须修复,不能只记录。 + +如果 `infraguard_scan` 返回工具错误或错误 payload(例如 `command_not_found`、`timeout`、`malformed_json`、`unexpected_exit_code`、`unknown_policy_aspect`),本步骤不能继续走成功收口。必须记录可处理的错误上下文:错误类型、`command`、`stderr` 摘要、`file_path`、`selected_aspects`,以及已经展开出的 policies(如果工具返回了)。不要在 skill 内执行策略更新命令,不要调用 `complete_step` 输出 `validated=true` 或 `review_passed=true`。 + +## 直接修复流程 + +1. 读取 `template.file_path`。 +2. 根据 `intent`、`candidate` 和模板资源选择 `selected_aspects`,只选择 step config 中存在的 aspect key。 +3. 使用 step config 值调用 `infraguard_scan`,传入 `selected_aspects`、`aspect_policy_map=config.infraguard.aspects`,并设置 `include_file_content=true`。 +4. 如果初始 `infraguard_scan` 返回 `passed=true`、`blocking_findings=0`,且没有需要修复的 finding,不要调用 `ros_validate_template`,不要再次调用 `infraguard_scan`;直接使用这次扫描返回的 `file_content` 和 `file_sha256` 完成。 +5. 如果 findings 需要修改模板,根据 findings 修改原模板文件;使用 `edit_file` 或 `write_file` 写回同一个 `template.file_path`。 +6. 只要本步骤修改过 `template.file_path`,就必须调用 `ros_validate_template(template_url=template.file_path)`。 +7. 如果 `ros_validate_template` 失败,分析错误,必要时使用 `aliyun_doc_search` 查询 ROS schema/文档,然后继续修复同一个原模板文件。 +8. 修复轮数不得超过 step config 的 `max_fix_rounds`。如果到达上限仍无法通过,不要伪造通过结论。 +9. `ros_validate_template` 成功后,最终再次运行 `infraguard_scan`,仍使用相同 `selected_aspects`、step config 和同一个 `template.file_path`,并设置 `include_file_content=true`。 +10. 只有最新可用扫描 `passed=true` 且 `blocking_findings=0` 时,才能调用 `complete_step`。如果本步骤没有改动模板,初始扫描就是最新可用扫描;如果改动过模板,最终扫描才是最新可用扫描。 + +> **template_url 支持本地文件路径**:`ros_validate_template` 的 `template_url` 可传 `template.file_path` 本地路径,工具会自动读取文件内容。不要把大模板内容直接塞进 API 参数。 + +## 修复原则 + +- 保持模板是 ROS YAML/JSON,不引入 Terraform 或其他 IaC 格式。 +- 修复依据只能来自 InfraGuard findings、`ros_validate_template` 错误或用户明确约束;不要在技能内硬编码额外的安全、合规、架构规则。 +- 优先做最小修复:只改动能够消除对应 finding 或校验错误的资源、属性和引用,保留用户意图与候选方案的设计边界。 +- 不要改变用户明确要求的资源生命周期;已有资源仍应建模为 Parameters 或外部引用,不要擅自在 `Resources` 中创建。 +- 修改库存相关属性时,遵循模板生成技能的参数化约束,不硬编码实例规格、可用区等库存值。 +- 如果某个 finding 无法从模板静态修复,记录到 `review_issues`,但只有 blocking findings 为 0 且 `ros_validate_template` 通过时才能完成。 + +## 完成条件 + +调用 `complete_step` 前必须同时满足: + +- 已经对同一个 `template.file_path` 执行 `infraguard_scan`。 +- 最新可用 `infraguard_scan` 结果中 `passed=true` 且 `blocking_findings=0`。 +- 最新可用 `infraguard_scan` 必须使用 `include_file_content=true`,并返回 `file_content`、`file_sha256`、`selected_aspects` 和 `expanded_policies`。 +- 如果本步骤对同一个 `template.file_path` 执行过 `edit_file` 或 `write_file`,必须先对同一个 `template.file_path` 调用 `ros_validate_template(template_url=template.file_path)` 并成功,然后执行最终 `infraguard_scan`。 +- 如果本步骤没有修改模板,初始 clean 的 `infraguard_scan` 可直接作为最新可用扫描,不要额外调用 `ros_validate_template` 或第二次 `infraguard_scan`。 +- `complete_step.conclusion.template` 必须精确等于最新可用 `infraguard_scan.file_content`。 +- `complete_step.conclusion.template_sha256` 必须精确等于最新可用 `infraguard_scan.file_sha256`,也必须等于 `conclusion.template` 的 sha256。 +- 不要凭内存、摘要、旧版本 `read_file` 或旧扫描结果填写 `conclusion.template`。 ## 输出 -调用 `complete_step` 提交结论。字段定义见 tool schema。 -如果 `passed` 为 `false`(存在 high 级问题),**同时必须**填写 `rollback_request`,target_step 为 `template_generating`。 +`complete_step` 的 `conclusion` 必须符合 schema: + +- `template`: 最新模板内容,必须原样使用最新可用 `infraguard_scan.file_content` 返回的完整内容。 +- `template_sha256`: 最新可用 `infraguard_scan.file_sha256`,必须是 `template` 字符串的 sha256。 +- `file_path`: 原 `template.file_path`,不要换路径。 +- `region`: 原地域。 +- `description`: 模板说明,可补充修复摘要。 +- `validated`: 固定为 `true`。 +- `review_passed`: 固定为 `true`。 +- `review_issues`: 数组,记录 InfraGuard findings、`ros_validate_template` 错误和对应修复;无问题则为空数组。 +- `selected_review_aspects`: 数组,记录已选择的 aspect key、名称和选择原因。 +- `skipped_review_aspects`: 数组,记录未选择的 aspect key、名称和跳过原因。 +- `resolved_infraguard_policies`: 数组,记录最新可用扫描使用的 InfraGuard policy id,来自 `infraguard_scan.expanded_policies`。 +- `infraguard_summary`: 最新可用扫描摘要,至少保留 `passed`、`blocking_findings`、`summary` 或关键 findings 数量。 +- `fix_summary`: 简短说明修复了什么;若无需修复,说明初始 InfraGuard 扫描通过且没有修改模板。 diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-review/evals.json b/src/iac_code/pipeline/selling/skills/iac-aliyun-review/evals.json new file mode 100644 index 00000000..fbfc5fb6 --- /dev/null +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-review/evals.json @@ -0,0 +1,284 @@ +{ + "skill_name": "iac-aliyun-review", + "description": "非 CI 评估用例:验证 review step 使用 InfraGuard 审查 ROS 模板;初始扫描干净时直接完成,发生修复时通过 ros_validate_template 和最终扫描后输出 template 结论。", + "non_ci": true, + "shared_step_config": { + "infraguard": { + "mode": "static", + "aspects": { + "security": { + "policies": ["pack:aliyun:security"] + }, + "high_availability": { + "policies": ["pack:aliyun:high-availability"] + }, + "cost_optimization": { + "policies": ["pack:aliyun:cost-optimization"] + }, + "compliance": { + "policies": ["pack:aliyun:compliance"] + }, + "best_practice": { + "policies": ["pack:aliyun:best-practice"] + }, + "operations": { + "policies": ["pack:aliyun:operations"] + }, + "network_architecture": { + "policies": ["pack:aliyun:network-architecture"] + }, + "elasticity": { + "policies": ["pack:aliyun:elasticity"] + } + }, + "ignore_waivers": true, + "max_fix_rounds": 5, + "blocking_severities": ["critical", "high"] + } + }, + "last_local_real_run": { + "date": "2026-07-01", + "ci": false, + "runner": "local manual verification", + "infraguard": { + "version": "0.10.0", + "opa_version": "1.12.1", + "asset": "cli/v0.10.0/infraguard-v0.10.0-darwin-arm64", + "sha256": "a19c6d755ec0ed1789e47019e3df6e81cdee1677d5b47674a6acfc678d34d76b" + }, + "policy_update": { + "command": "infraguard policy update", + "status": "interrupted_after_network_stall", + "note": "The command started a git clone from github.com/aliyun/infraguard and was interrupted after the clone stalled for more than 9 minutes in this local network. The bundled policy catalog was still available via policy list." + }, + "policy_catalog_observed": { + "packs": 35, + "rules": 317, + "required_policy_ids_present": [ + "pack:aliyun:security", + "pack:aliyun:operations", + "pack:aliyun:high-availability" + ] + }, + "scan_observations": [ + { + "name": "insecure-public-ecs", + "fixture": "{TMPDIR}/insecure-public-ecs.yml", + "command": "infraguard scan {fixture} --format json --mode static --policy pack:aliyun:security --policy pack:aliyun:operations --policy pack:aliyun:high-availability --no-waivers", + "exit_code": 2, + "summary": { + "total_violations": 1, + "severity_counts": {"high": 1, "medium": 0, "low": 0}, + "files_scanned": 1, + "files_with_violations": 1 + }, + "first_violation": { + "id": "rule:aliyun:ecs-instance-no-public-ip", + "severity": "high", + "resource_id": "EcsGroup", + "violation_path": ["Properties", "AllocatePublicIP"], + "reason": "ECS instance has a public IP bound", + "recommendation": "Use NAT Gateway or SLB for internet access instead of direct public IP binding" + }, + "json_shape": "schema_version=2.0 with results[].violations[]" + }, + { + "name": "repaired-private-ecs", + "fixture": "{TMPDIR}/repaired-private-ecs.yml", + "command": "infraguard scan {fixture} --format json --mode static --policy pack:aliyun:security --policy pack:aliyun:operations --policy pack:aliyun:high-availability --no-waivers", + "exit_code": 0, + "summary": { + "total_violations": 0, + "severity_counts": {"high": 0, "medium": 0, "low": 0}, + "files_scanned": 1, + "files_with_violations": 0 + }, + "json_shape": "schema_version=2.0 with results[].violations=null for clean files" + } + ], + "validate_template": { + "status": "not_run_missing_credentials", + "credential_env_checked": [ + "ALIBABA_CLOUD_ACCESS_KEY_ID", + "ALIBABA_CLOUD_ACCESS_KEY_SECRET", + "ALIBABA_CLOUD_REGION_ID", + "ALICLOUD_ACCESS_KEY_ID", + "ALICLOUD_ACCESS_KEY_SECRET", + "ALICLOUD_REGION_ID" + ], + "note": "All checked credential environment variables were unset; no credential values were read or printed." + } + }, + "evals": [ + { + "id": 1, + "name": "public-ecs-high-finding-is-fixed-in-place", + "prompt": "审查上一步生成的 ROS 模板。模板创建一台 ECS,ECSGroup 设置 AllocatePublicIP: true,SecurityGroupIngress 对 0.0.0.0/0 开放 22 端口。", + "intent_context": { + "business_type": "demo", + "non_functional": { + "availability": "低成本单机", + "compliance": "未提出" + } + }, + "candidate_context": { + "name": "低成本单机 ECS 方案", + "topology": "单可用区 VPC 内 ECS", + "tradeoffs": ["成本优先", "不承诺多可用区高可用"] + }, + "template_context": { + "file_path": "{TMPDIR}/insecure-public-ecs.yml", + "region": "cn-hangzhou", + "description": "VPC + VSwitch + SecurityGroup + ECS InstanceGroup,ECS 直接绑定公网 IP。", + "template_contains": [ + "Type: ALIYUN::ECS::InstanceGroup", + "AllocatePublicIP: true", + "InternetMaxBandwidthOut: 5", + "SourceCidrIp: 0.0.0.0/0" + ] + }, + "observed_infraguard_baseline": { + "exit_code": 2, + "blocking_findings": 1, + "finding": { + "id": "rule:aliyun:ecs-instance-no-public-ip", + "severity": "high", + "resource_id": "EcsGroup", + "violation_path": ["Properties", "AllocatePublicIP"] + } + }, + "expected_behavior": "先读取同一个 file_path,使用 step config 调用 infraguard_scan,发现 high/blocking finding 后只针对 InfraGuard 返回的 finding 修复原模板文件;修复后调用 ros_validate_template 校验同一个 template_url;ros_validate_template 成功后再次调用 infraguard_scan,最终 blocking_findings=0 才 complete_step。", + "expected_tool_sequence": [ + "read_file(template.file_path)", + "select configured aspect keys from intent + candidate + template", + "infraguard_scan(initial, selected_aspects + aspect_policy_map=step_config.infraguard.aspects)", + "edit_file or write_file(same template.file_path)", + "ros_validate_template(template_url=template.file_path)", + "infraguard_scan(final, same selected_aspects, after ros_validate_template, same template.file_path)", + "read_file(same template.file_path)", + "complete_step(conclusion_field=template)" + ], + "assertions": [ + {"name": "selects_aspects_from_context", "check": "根据 intent、candidate 和模板资源选择 step_config.infraguard.aspects 中存在的 aspect key"}, + {"name": "records_aspect_decisions", "check": "complete_step.conclusion 包含 selected_review_aspects、skipped_review_aspects 和 resolved_infraguard_policies"}, + {"name": "uses_aspect_policy_map", "check": "infraguard_scan 使用 selected_aspects 和 aspect_policy_map 展开 policies,不让模型手写或编造 policy id"}, + {"name": "fixes_reported_public_ip_finding", "check": "修复 InfraGuard 返回的 ecs-instance-no-public-ip finding;不要因为硬编码安全规则额外改写未被 finding 或校验错误覆盖的内容"}, + {"name": "same_file_path", "check": "所有 read/write/ros_validate_template/final scan 都使用输入的 template.file_path"}, + {"name": "validates_after_edit", "check": "模板写回后调用 ros_validate_template,参数使用 template_url=file_path,不把大模板内容塞进工具参数"}, + {"name": "final_scan_after_validation", "check": "最终 infraguard_scan 发生在 ros_validate_template 成功之后"}, + {"name": "final_scan_blocks_completion", "check": "final infraguard_scan 返回 passed=true 且 blocking_findings=0 后才能 complete_step"}, + {"name": "conclusion_template_matches_file", "check": "complete_step.conclusion.template 与最终文件内容一致"}, + {"name": "no_policy_update_inside_skill", "check": "review skill 不调用 infraguard policy update,策略更新只属于 pipeline prerequisites"} + ] + }, + { + "id": 2, + "name": "clean-template-skips-validate-and-rescan", + "prompt": "审查上一步生成的 ROS 模板。模板只创建 VPC、VSwitch、SecurityGroup 和一台无公网 IP 的 ECS,SSH 入口只允许 10.0.0.0/8。", + "intent_context": { + "business_type": "demo", + "non_functional": {"availability": "低成本单机"} + }, + "candidate_context": { + "name": "私网 ECS 低成本方案", + "topology": "single-zone ECS without public IP" + }, + "template_context": { + "file_path": "{TMPDIR}/repaired-private-ecs.yml", + "region": "cn-hangzhou", + "description": "初始 InfraGuard 扫描无 high finding 的私网 ECS 模板。", + "template_contains": [ + "Type: ALIYUN::ECS::InstanceGroup", + "SourceCidrIp: 10.0.0.0/8" + ], + "template_not_contains": [ + "AllocatePublicIP: true", + "InternetMaxBandwidthOut: 5" + ] + }, + "observed_infraguard_baseline": { + "exit_code": 0, + "blocking_findings": 0, + "summary": {"total_violations": 0} + }, + "expected_behavior": "初始 InfraGuard 扫描无 blocking finding 且没有需要修复的 finding 时,不应修改模板,不应调用 ros_validate_template,也不应再次执行最终 infraguard_scan;应直接使用初始扫描返回的 file_content、file_sha256、expanded_policies 完成。", + "assertions": [ + {"name": "skips_unneeded_aspects_with_reason", "check": "低成本单机方案应跳过 high_availability 和 elasticity,并在 skipped_review_aspects 说明原因"}, + {"name": "does_not_make_unneeded_edits", "check": "无 finding 时不做无关模板改写"}, + {"name": "skips_validate_template_when_unchanged", "check": "初始扫描通过且模板未修改时,不调用 ros_validate_template"}, + {"name": "skips_final_rescan_when_unchanged", "check": "初始扫描通过且模板未修改时,不第二次调用 infraguard_scan"}, + {"name": "empty_review_issues", "check": "如果没有发现和修复的问题,review_issues 为空数组"}, + {"name": "fix_summary_says_no_fix_needed", "check": "fix_summary 说明初始 InfraGuard 扫描通过且无需修复"} + ] + }, + { + "id": 3, + "name": "validate-template-failure-repair-loop", + "prompt": "审查模板。InfraGuard 扫描返回需要修复的 finding;修复后 ros_validate_template 返回错误:ImageId 引用了不存在的参数 NonExistentParam,或 VSwitch 缺少必填 CidrBlock。", + "template_context": { + "file_path": "{TMPDIR}/validation-broken.yml", + "region": "cn-hangzhou", + "description": "InfraGuard 修复后触发 ROS schema 校验失败的模板。", + "validation_error": "Template format error: unresolved Ref NonExistentParam; Resource VSwitch property CidrBlock is required." + }, + "expected_behavior": "只要本步骤修改过模板,就必须调用 ros_validate_template;ros_validate_template 失败时,必须根据错误修复同一个模板文件,必要时调用 aliyun_doc_search 查询 schema,重新 ros_validate_template,成功后再做最终 infraguard_scan。", + "assertions": [ + {"name": "validate_template_is_gate", "check": "ros_validate_template 失败时不得 complete_step"}, + {"name": "uses_schema_lookup_when_needed", "check": "对资源属性不确定时调用 aliyun_doc_search"}, + {"name": "edits_same_file", "check": "修复 NonExistentParam/CidrBlock 等错误时写回同一个 template.file_path"}, + {"name": "revalidates_after_fix", "check": "每次模板修复后重新调用 ros_validate_template"}, + {"name": "final_scan_after_successful_validation", "check": "只有 ros_validate_template 成功后才执行最终 infraguard_scan"} + ] + }, + { + "id": 4, + "name": "infraguard-v010-json-normalization", + "prompt": "review step 调用 infraguard_scan 时,底层 InfraGuard 0.10.x 返回 schema_version=2.0,findings 位于 results[].violations[];无问题文件返回 results[].violations=null。", + "expected_behavior": "infraguard_scan 工具必须把 results[].violations[] 归一化为 findings 数组,并按 violation.severity 计算 blocking_findings;对于 violations=null 的干净文件,findings 应为空数组。", + "assertions": [ + {"name": "expands_selected_aspects", "check": "工具根据 selected_aspects 和 aspect_policy_map 展开 expanded_policies,并用这些 policies 调用 InfraGuard"}, + {"name": "flattens_nested_violations", "check": "高危 violation rule:aliyun:ecs-instance-no-public-ip 被暴露为 findings[0]"}, + {"name": "counts_nested_severity", "check": "blocking_severities=[high] 时 blocking_findings=1 且 passed=false"}, + {"name": "ignores_empty_violation_group", "check": "violations=null 时 findings=[]、blocking_findings=0、passed=true"}, + {"name": "keeps_repair_context", "check": "归一化后的 finding 保留 resource_id、violation_path、reason、recommendation、file/line/snippet 等修复上下文"} + ] + }, + { + "id": 5, + "name": "tool-error-or-policy-missing-does-not-complete", + "prompt": "审查模板时 infraguard_scan 返回 command_not_found、timeout、malformed_json、unexpected_exit_code,或策略 ID 不可用导致扫描失败。", + "expected_behavior": "review step 必须把扫描失败视为步骤失败或可恢复前置条件问题,不能伪造 review_passed=true,也不能在缺少必要 InfraGuard 扫描结果时 complete_step。", + "assertions": [ + {"name": "no_complete_on_scan_error", "check": "infraguard_scan is_error=true 时不得调用 complete_step"}, + {"name": "reports_actionable_error", "check": "向 pipeline/session 记录 InfraGuard 命令、错误类型和 stderr 摘要,便于 prerequisite 或用户处理"}, + {"name": "does_not_run_policy_update", "check": "即使策略缺失,skill 内也不运行 infraguard policy update;应由 prerequisites 负责"}, + {"name": "does_not_claim_validated", "check": "扫描失败时不得输出 validated=true 或 review_passed=true 的 conclusion"} + ] + }, + { + "id": 6, + "name": "max-fix-rounds-prevents-fake-pass", + "prompt": "审查模板时,每轮修复后 InfraGuard 仍返回 high finding,或 ros_validate_template 仍失败,直到达到 step config 的 max_fix_rounds=5。", + "expected_behavior": "达到 max_fix_rounds 后必须停止并如实失败,不得调用 complete_step 输出通过结论;不得 rollback 到 template_generating 来规避 review 责任。", + "assertions": [ + {"name": "honors_max_fix_rounds", "check": "修复轮数不超过 step_config.infraguard.max_fix_rounds"}, + {"name": "no_fake_review_passed", "check": "修改过模板但未通过 ros_validate_template 或最终 scan 时不输出 review_passed=true"}, + {"name": "no_fake_validated", "check": "ros_validate_template 未成功时不输出 validated=true"}, + {"name": "no_default_rollback", "check": "不默认 rollback_request 到 template_generating"}, + {"name": "records_remaining_issues", "check": "失败结果记录剩余 findings 或 ros_validate_template 错误"} + ] + }, + { + "id": 7, + "name": "nonblocking-findings-are-recorded-not-blocking", + "prompt": "审查模板时 InfraGuard 扫描没有 high/blocking finding,但仍存在 medium 或 low finding,blocking_severities 只配置为 [high]。", + "expected_behavior": "可以在 blocking_findings=0 后完成,但必须在 review_issues 或 infraguard_summary 中记录非阻塞 finding;若修复简单且低风险,可以一并修复,修复后必须再验证和最终扫描。", + "assertions": [ + {"name": "uses_configured_blocking_severities", "check": "只按 step config 的 blocking_severities 判定是否阻塞完成"}, + {"name": "records_nonblocking_findings", "check": "medium/low finding 不被丢弃,出现在 review_issues 或 infraguard_summary 中"}, + {"name": "validate_template_required_after_edit", "check": "如果为非阻塞 finding 修改了模板,仍需 ros_validate_template 成功"}, + {"name": "final_scan_required_after_edit", "check": "如果为非阻塞 finding 修改了模板,complete_step 前仍需最终 infraguard_scan"} + ] + } + ] +} diff --git a/src/iac_code/pipeline/selling/tools/__init__.py b/src/iac_code/pipeline/selling/tools/__init__.py index e69de29b..7d426cab 100644 --- a/src/iac_code/pipeline/selling/tools/__init__.py +++ b/src/iac_code/pipeline/selling/tools/__init__.py @@ -0,0 +1,3 @@ +from iac_code.pipeline.selling.tools.infraguard_scan_tool import InfraGuardScanTool + +__all__ = ["InfraGuardScanTool"] diff --git a/src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py b/src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py new file mode 100644 index 00000000..b68b3e31 --- /dev/null +++ b/src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py @@ -0,0 +1,810 @@ +"""InfraGuard scan tool for pipeline review gates.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import shlex +import signal +import subprocess +from contextlib import suppress +from pathlib import Path +from typing import Any + +from iac_code.i18n import _ +from iac_code.tools.base import Tool, ToolContext, ToolResult + +_SCAN_TIMEOUT_SECONDS = 120 +_TOOL_TIMEOUT_BUFFER_SECONDS = 15 + + +def _extract_findings(parsed: dict[str, Any]) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + top_level_findings = parsed.get("findings") + if isinstance(top_level_findings, list): + findings.extend(finding for finding in top_level_findings if isinstance(finding, dict)) + + top_level_violations = parsed.get("violations") + if isinstance(top_level_violations, list): + findings.extend(violation for violation in top_level_violations if isinstance(violation, dict)) + + raw_results = parsed.get("results") + if not isinstance(raw_results, list): + return findings + + for finding in raw_results: + if not isinstance(finding, dict): + continue + if "violations" not in finding: + findings.append(finding) + continue + violations = finding.get("violations") + if not isinstance(violations, list): + continue + for violation in violations: + if not isinstance(violation, dict): + continue + normalized = dict(violation) + if "file" not in normalized and isinstance(finding.get("file"), str): + normalized["file"] = finding["file"] + findings.append(normalized) + return findings + + +def _looks_like_scan_payload(parsed: dict[str, Any]) -> bool: + return any(key in parsed for key in ("results", "findings", "violations", "summary")) + + +def _summary_blocking_findings(summary: dict[str, Any], blocking_severities: set[str]) -> int: + severity_counts = summary.get("severity_counts") + candidates = [summary] + if isinstance(severity_counts, dict): + candidates.append(severity_counts) + + max_count = 0 + for candidate in candidates: + total = 0 + for severity in blocking_severities: + value = candidate.get(severity) + if isinstance(value, bool): + continue + if isinstance(value, int): + total += value + continue + if isinstance(value, str): + try: + total += int(value) + except ValueError: + continue + max_count = max(max_count, total) + return max_count + + +def _summary_reports_findings(summary: dict[str, Any]) -> bool: + severity_counts = summary.get("severity_counts") + if isinstance(severity_counts, dict): + for value in severity_counts.values(): + if isinstance(value, bool): + continue + if isinstance(value, int) and value > 0: + return True + if isinstance(value, str): + try: + if int(value) > 0: + return True + except ValueError: + continue + + for key in ("total", "total_violations", "violations", "files_with_violations"): + value = summary.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int) and value > 0: + return True + if isinstance(value, str): + try: + if int(value) > 0: + return True + except ValueError: + continue + return False + + +def _scan_file_path(file_path: str, cwd: str) -> Path: + path = Path(file_path).expanduser() + if not path.is_absolute(): + path = Path(cwd) / path + return path + + +def _file_sha256(path: Path) -> str | None: + try: + hasher = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() + except OSError: + return None + + +def _read_file_content(path: Path) -> str | None: + try: + with path.open("r", encoding="utf-8", newline="") as file: + return file.read() + except OSError: + return None + except UnicodeDecodeError: + return None + + +def _list_of_strings(raw: Any) -> list[str]: + if not isinstance(raw, list): + return [] + return [str(item) for item in raw if isinstance(item, str) and item] + + +def _unsupported_no_waivers_flag(completed: subprocess.CompletedProcess[str]) -> bool: + stderr = (completed.stderr or "").lower() + unsupported_phrases = ("unknown flag", "unknown option", "unrecognized flag", "unrecognized option") + return ( + completed.returncode != 0 + and "--no-waivers" in stderr + and any(phrase in stderr for phrase in unsupported_phrases) + ) + + +async def _run_infraguard_command( + command: list[str], + *, + cwd: str, + timeout_seconds: float, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + popen_kwargs: dict[str, Any] = {} + if os.name == "nt": + popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + else: + popen_kwargs["start_new_session"] = True + + process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + env=_environment_with_overrides(env), + **popen_kwargs, + ) + try: + stdout_bytes, stderr_bytes = await asyncio.wait_for(process.communicate(), timeout=timeout_seconds) + except TimeoutError as exc: + stdout_bytes, stderr_bytes = await _terminate_infraguard_process(process) + raise subprocess.TimeoutExpired( + command, + timeout_seconds, + output=_decode_process_output(stdout_bytes), + stderr=_decode_process_output(stderr_bytes), + ) from exc + except asyncio.CancelledError: + await _terminate_infraguard_process(process) + raise + + return subprocess.CompletedProcess( + command, + process.returncode if process.returncode is not None else 0, + stdout=_decode_process_output(stdout_bytes), + stderr=_decode_process_output(stderr_bytes), + ) + + +def _environment_with_overrides(env_overrides: dict[str, str] | None) -> dict[str, str] | None: + if not env_overrides: + return None + env = os.environ.copy() + env.update(env_overrides) + return env + + +async def _terminate_infraguard_process(process: asyncio.subprocess.Process) -> tuple[bytes, bytes]: + _terminate_infraguard_process_tree(process) + try: + return await asyncio.wait_for(process.communicate(), timeout=3) + except TimeoutError: + _terminate_infraguard_process_tree(process, force=True) + with suppress(Exception): + return await asyncio.wait_for(process.communicate(), timeout=3) + return b"", b"" + + +def _terminate_infraguard_process_tree( + process: asyncio.subprocess.Process, + *, + force: bool = False, +) -> None: + if process.returncode is not None: + return + if os.name == "nt": + _terminate_windows_process_tree(process.pid, force=force) + return + try: + os.killpg(process.pid, signal.SIGKILL if force else signal.SIGTERM) + except ProcessLookupError: + return + except OSError: + if force: + process.kill() + else: + process.terminate() + + +def _terminate_windows_process_tree(pid: int, *, force: bool = False) -> None: + command = ["taskkill", "/T", "/PID", str(pid)] + if force: + command.insert(1, "/F") + try: + subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + except OSError: + return + + +def _decode_process_output(output: bytes | str | None) -> str: + if output is None: + return "" + if isinstance(output, str): + return output + return output.decode("utf-8", errors="replace") + + +def _expand_policies(tool_input: dict[str, Any]) -> tuple[list[str], list[str], list[str]]: + policies = _list_of_strings(tool_input.get("policies")) + selected_aspects = _list_of_strings(tool_input.get("selected_aspects")) + aspect_policy_map = tool_input.get("aspect_policy_map") + unknown_aspects: list[str] = [] + + if selected_aspects: + if not isinstance(aspect_policy_map, dict): + return policies, selected_aspects, selected_aspects + for aspect_key in selected_aspects: + raw_aspect = aspect_policy_map.get(aspect_key) + if not isinstance(raw_aspect, dict): + unknown_aspects.append(aspect_key) + continue + policies.extend(_list_of_strings(raw_aspect.get("policies"))) + + deduped_policies: list[str] = [] + seen: set[str] = set() + for policy in policies: + if policy in seen: + continue + seen.add(policy) + deduped_policies.append(policy) + return deduped_policies, selected_aspects, unknown_aspects + + +def _json_object(output: str) -> dict[str, Any] | None: + try: + parsed = json.loads(output) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, dict) else None + + +def _int_value(value: Any) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value) + except ValueError: + return None + return None + + +def _severity_counts(payload: dict[str, Any]) -> dict[str, int]: + summary = payload.get("summary") + candidates: list[dict[str, Any]] = [] + if isinstance(summary, dict): + severity_counts = summary.get("severity_counts") + if isinstance(severity_counts, dict): + candidates.append(severity_counts) + candidates.append(summary) + + counts: dict[str, int] = {} + for candidate in candidates: + for severity in ("critical", "high", "medium", "low"): + value = _int_value(candidate.get(severity)) + if value is not None: + counts[severity] = max(counts.get(severity, 0), value) + + findings = payload.get("findings") + if isinstance(findings, list): + for finding in findings: + if not isinstance(finding, dict): + continue + severity = str(finding.get("severity") or "").strip().lower() + if severity: + counts[severity] = max(counts.get(severity, 0), 0) + return counts + + +def _severity_counts_text(counts: dict[str, int]) -> str: + preferred = {"critical", "high", "medium", "low"} + ordered = [ + "{} {}".format(severity, counts[severity]) + for severity in ("critical", "high", "medium", "low") + if severity in counts + ] + extra = ["{} {}".format(key, value) for key, value in sorted(counts.items()) if key not in preferred] + return ", ".join([*ordered, *extra]) + + +def _finding_count(payload: dict[str, Any], counts: dict[str, int]) -> int: + findings = payload.get("findings") + if isinstance(findings, list): + return len([finding for finding in findings if isinstance(finding, dict)]) + summary = payload.get("summary") + if isinstance(summary, dict): + for key in ("total_violations", "total", "violations"): + value = _int_value(summary.get(key)) + if value is not None: + return value + return sum(counts.values()) + + +def _status_text(payload: dict[str, Any]) -> str: + if payload.get("error"): + return _("error") + if "passed" in payload: + return _("passed") if payload.get("passed") else _("failed") + return _("completed") + + +def _error_label(error: Any) -> str: + labels = { + "unsupported_no_waivers_flag": _("InfraGuard CLI does not support --no-waivers"), + } + key = str(error) + return labels.get(key, key) + + +def _plural_findings(count: int) -> str: + if count == 1: + return _("1 finding") + return _("{count} findings").format(count=count) + + +def _join_lines(lines: list[str]) -> str: + return "\n ".join(line for line in lines if line) + + +def _short_file_path(payload: dict[str, Any]) -> str: + return str(payload.get("file_path") or payload.get("canonical_file_path") or "") + + +def _format_command(command: Any) -> str: + if not isinstance(command, list): + return "" + return shlex.join(str(part) for part in command) + + +def _list_text(value: Any) -> str: + if isinstance(value, list): + return ", ".join(str(item) for item in value if item) + return "" + + +def _format_summary(summary: dict[str, Any]) -> list[str]: + lines: list[str] = [] + for key in ("total_violations", "files_scanned", "files_with_violations", "waived_count", "expired_waiver_count"): + value = summary.get(key) + if value is not None: + lines.append(" {}: {}".format(key, value)) + return lines + + +def _finding_title(finding: dict[str, Any]) -> str: + severity = str(finding.get("severity") or "unknown") + rule = str(finding.get("rule_id") or finding.get("rule") or finding.get("id") or "unknown") + resource = str(finding.get("resource_id") or finding.get("resource") or "") + line = finding.get("line") + pieces = [severity, rule] + if resource: + pieces.append(resource) + if line is not None: + pieces.append(_("line {line}").format(line=line)) + return " · ".join(pieces) + + +def _format_finding_lines(finding: dict[str, Any]) -> list[str]: + lines = ["- {}".format(_finding_title(finding))] + reason = finding.get("reason") + if reason: + lines.append(" " + _("Reason: {reason}").format(reason=reason)) + recommendation = finding.get("recommendation") + if recommendation: + lines.append(" " + _("Recommendation: {recommendation}").format(recommendation=recommendation)) + snippet = finding.get("snippet") + if snippet: + lines.append(" " + _("Snippet: {snippet}").format(snippet=snippet)) + return lines + + +def _render_infraguard_compact(payload: dict[str, Any]) -> str: + file_path = _short_file_path(payload) + if payload.get("error"): + parts = [_("error"), _error_label(payload.get("error"))] + if file_path: + parts.append(file_path) + return " · ".join(parts) + + counts = _severity_counts(payload) + finding_count = _finding_count(payload, counts) + parts = [ + _status_text(payload), + _plural_findings(finding_count), + _("blocking {count}").format(count=payload.get("blocking_findings", 0)), + ] + counts_text = _severity_counts_text(counts) + if counts_text: + parts.append(counts_text) + if file_path: + parts.append(file_path) + return " · ".join(parts) + + +def _render_infraguard_verbose(payload: dict[str, Any]) -> str: + lines: list[str] = [] + command = _format_command(payload.get("command")) + if command: + lines.append(_("Command: {command}").format(command=command)) + lines.append(_("Status: {status}").format(status=_status_text(payload))) + if payload.get("error"): + lines.append(_("Error: {error}").format(error=_error_label(payload.get("error")))) + file_path = _short_file_path(payload) + if file_path: + lines.append(_("File: {file_path}").format(file_path=file_path)) + if payload.get("mode") is not None: + lines.append(_("Mode: {mode}").format(mode=payload.get("mode"))) + if payload.get("exit_code") is not None: + lines.append(_("Exit code: {exit_code}").format(exit_code=payload.get("exit_code"))) + if payload.get("ignore_waivers") is not None: + lines.append(_("Ignore waivers: {value}").format(value=payload.get("ignore_waivers"))) + blocking_severities = _list_text(payload.get("blocking_severities")) + if blocking_severities: + lines.append(_("Blocking severities: {severities}").format(severities=blocking_severities)) + if payload.get("blocking_findings") is not None: + lines.append(_("Blocking findings: {count}").format(count=payload.get("blocking_findings"))) + selected_aspects = _list_text(payload.get("selected_aspects")) + if selected_aspects: + lines.append(_("Aspects: {aspects}").format(aspects=selected_aspects)) + + policies = [str(policy) for policy in payload.get("expanded_policies") or [] if policy] + if policies: + lines.append(_("Policies:")) + lines.extend(" - {}".format(policy) for policy in policies) + + summary = payload.get("summary") + counts = _severity_counts(payload) + if isinstance(summary, dict) or counts: + lines.append(_("Summary:")) + if counts: + lines.append(" " + _("Severity counts: {counts}").format(counts=_severity_counts_text(counts))) + if isinstance(summary, dict): + lines.extend(_format_summary(summary)) + + stderr = payload.get("stderr") + if stderr: + lines.append(_("Stderr: {stderr}").format(stderr=stderr)) + + findings = [finding for finding in payload.get("findings") or [] if isinstance(finding, dict)] + lines.append(_("Findings:")) + if findings: + for finding in findings: + lines.extend(_format_finding_lines(finding)) + else: + lines.append(" " + _("No findings.")) + return _join_lines(lines) + + +class InfraGuardScanTool(Tool): + """Run InfraGuard and normalize scan results for deterministic completion guards.""" + + def __init__(self, *, step_config: dict[str, Any] | None = None) -> None: + raw_infraguard = (step_config or {}).get("infraguard") if isinstance(step_config, dict) else None + self._configured_infraguard = dict(raw_infraguard) if isinstance(raw_infraguard, dict) else {} + + @property + def name(self) -> str: + return "infraguard_scan" + + @property + def description(self) -> str: + return _("Run InfraGuard static scan and return structured JSON results.") + + @property + def timeout(self) -> float | None: + return _SCAN_TIMEOUT_SECONDS + _TOOL_TIMEOUT_BUFFER_SECONDS + + @property + def input_schema(self) -> dict[str, Any]: + return { + "type": "object", + "required": ["file_path"], + "properties": { + "file_path": {"type": "string"}, + "mode": {"type": "string", "enum": ["static", "preview"], "default": "static"}, + "policies": {"type": "array", "items": {"type": "string"}}, + "selected_aspects": {"type": "array", "items": {"type": "string"}}, + "aspect_policy_map": {"type": "object"}, + "ignore_waivers": {"type": "boolean", "default": True}, + "blocking_severities": {"type": "array", "items": {"type": "string"}, "default": ["high"]}, + "include_file_content": {"type": "boolean", "default": False}, + }, + } + + def is_read_only(self, input: dict | None = None) -> bool: + return True + + @property + def render_verbose_result_in_transcript(self) -> bool: + return True + + def render_tool_result_message(self, output: str, *, is_error: bool = False, verbose: bool = False) -> str | None: + payload = _json_object(output) + if payload is None: + return None + return _render_infraguard_verbose(payload) if verbose else _render_infraguard_compact(payload) + + async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + tool_input = self._apply_configured_infraguard_defaults(tool_input) + file_path = str(tool_input["file_path"]) + mode = str(tool_input.get("mode") or "static") + aspect_policy_map_present = isinstance(tool_input.get("aspect_policy_map"), dict) + selected_aspects_for_contract = _list_of_strings(tool_input.get("selected_aspects")) + if aspect_policy_map_present and _list_of_strings(tool_input.get("policies")): + return ToolResult( + content=json.dumps( + { + "error": "raw_policies_not_allowed_with_aspects", + "file_path": file_path, + "selected_aspects": selected_aspects_for_contract, + }, + ensure_ascii=False, + ), + is_error=True, + ) + if aspect_policy_map_present and not selected_aspects_for_contract: + return ToolResult( + content=json.dumps( + { + "error": "selected_aspects_required", + "file_path": file_path, + "selected_aspects": [], + }, + ensure_ascii=False, + ), + is_error=True, + ) + policies, selected_aspects, unknown_aspects = _expand_policies(tool_input) + if unknown_aspects: + return ToolResult( + content=json.dumps( + { + "error": "unknown_policy_aspect", + "unknown_aspects": unknown_aspects, + "selected_aspects": selected_aspects, + "file_path": file_path, + }, + ensure_ascii=False, + ), + is_error=True, + ) + command = ["infraguard", "scan", file_path, "--format", "json", "--mode", mode] + + for policy in policies: + command.extend(["--policy", policy]) + if tool_input.get("ignore_waivers", True): + command.append("--no-waivers") + + try: + completed = await _run_infraguard_command( + command, + cwd=context.cwd, + timeout_seconds=_SCAN_TIMEOUT_SECONDS, + env=context.env_overrides, + ) + if "--no-waivers" in command and _unsupported_no_waivers_flag(completed): + return ToolResult( + content=json.dumps( + { + "error": "unsupported_no_waivers_flag", + "command": command, + "exit_code": completed.returncode, + "stderr": completed.stderr, + "file_path": file_path, + "selected_aspects": selected_aspects, + "expanded_policies": policies, + "ignore_waivers": True, + }, + ensure_ascii=False, + ), + is_error=True, + ) + except FileNotFoundError as error: + return ToolResult( + content=json.dumps( + { + "error": "command_not_found", + "command": command, + "stderr": str(error), + "file_path": file_path, + "selected_aspects": selected_aspects, + "expanded_policies": policies, + }, + ensure_ascii=False, + ), + is_error=True, + ) + except subprocess.TimeoutExpired as error: + return ToolResult( + content=json.dumps( + { + "error": "timeout", + "command": command, + "stderr": str(error), + "timeout": error.timeout, + "file_path": file_path, + "selected_aspects": selected_aspects, + "expanded_policies": policies, + }, + ensure_ascii=False, + ), + is_error=True, + ) + + if completed.returncode not in {0, 1, 2}: + return ToolResult( + content=json.dumps( + { + "error": "unexpected_exit_code", + "command": command, + "exit_code": completed.returncode, + "stderr": completed.stderr, + "file_path": file_path, + "selected_aspects": selected_aspects, + "expanded_policies": policies, + }, + ensure_ascii=False, + ), + is_error=True, + ) + + try: + parsed = json.loads(completed.stdout) + except json.JSONDecodeError: + return ToolResult( + content=json.dumps( + { + "error": "malformed_json", + "command": command, + "exit_code": completed.returncode, + "stderr": completed.stderr, + "file_path": file_path, + "selected_aspects": selected_aspects, + "expanded_policies": policies, + }, + ensure_ascii=False, + ), + is_error=True, + ) + + if not isinstance(parsed, dict): + return ToolResult( + content=json.dumps( + { + "error": "malformed_json", + "command": command, + "exit_code": completed.returncode, + "stderr": completed.stderr, + "file_path": file_path, + "selected_aspects": selected_aspects, + "expanded_policies": policies, + }, + ensure_ascii=False, + ), + is_error=True, + ) + + if "error" in parsed or not _looks_like_scan_payload(parsed): + error_value = parsed.get("error") if "error" in parsed else "invalid_scan_payload" + return ToolResult( + content=json.dumps( + { + "error": str(error_value), + "command": command, + "exit_code": completed.returncode, + "stderr": completed.stderr, + "file_path": file_path, + "selected_aspects": selected_aspects, + "expanded_policies": policies, + "details": parsed, + }, + ensure_ascii=False, + ), + is_error=True, + ) + + scan_path = _scan_file_path(file_path, context.cwd) + file_sha256 = _file_sha256(scan_path) + findings = _extract_findings(parsed) + summary = parsed.get("summary", {}) + if not isinstance(summary, dict): + summary = {} + + blocking_severities = { + str(severity).strip().lower() for severity in tool_input.get("blocking_severities", ["high"]) if severity + } + findings_blocking_count = sum( + 1 + for finding in findings + if isinstance(finding, dict) and str(finding.get("severity", "")).strip().lower() in blocking_severities + ) + blocking_findings = max(findings_blocking_count, _summary_blocking_findings(summary, blocking_severities)) + if ( + completed.returncode == 2 + and blocking_findings == 0 + and not findings + and not _summary_reports_findings(summary) + ): + return ToolResult( + content=json.dumps( + { + "error": "inconsistent_scan_payload", + "command": command, + "exit_code": completed.returncode, + "stderr": completed.stderr, + "file_path": file_path, + "summary": summary, + "findings": findings, + "selected_aspects": selected_aspects, + "expanded_policies": policies, + }, + ensure_ascii=False, + ), + is_error=True, + ) + payload = { + "command": command, + "exit_code": completed.returncode, + "mode": mode, + "ignore_waivers": bool(tool_input.get("ignore_waivers", True)), + "blocking_severities": list(tool_input.get("blocking_severities", ["high"])), + "passed": blocking_findings == 0, + "blocking_findings": blocking_findings, + "findings": findings, + "summary": summary, + "file_path": file_path, + "canonical_file_path": str(scan_path), + "file_sha256": file_sha256, + "selected_aspects": selected_aspects, + "expanded_policies": policies, + } + if tool_input.get("include_file_content", False): + payload["file_content"] = _read_file_content(scan_path) + return ToolResult(content=json.dumps(payload, ensure_ascii=False), is_error=False) + + def _apply_configured_infraguard_defaults(self, tool_input: dict[str, Any]) -> dict[str, Any]: + if not self._configured_infraguard: + return dict(tool_input) + resolved = dict(tool_input) + for key in ("mode", "ignore_waivers", "blocking_severities"): + if key in self._configured_infraguard: + resolved[key] = self._configured_infraguard[key] + aspects = self._configured_infraguard.get("aspects") + if isinstance(aspects, dict): + resolved["aspect_policy_map"] = aspects + return resolved diff --git a/src/iac_code/services/telemetry/content_serializer.py b/src/iac_code/services/telemetry/content_serializer.py index 4a4f2feb..ee8f910e 100644 --- a/src/iac_code/services/telemetry/content_serializer.py +++ b/src/iac_code/services/telemetry/content_serializer.py @@ -10,6 +10,8 @@ import json from typing import Any +from iac_code.utils.tool_result_redaction import redact_tool_result_file_content + _MAX_CONTENT_BYTES = 4096 @@ -24,6 +26,13 @@ def _json_dumps(obj: Any) -> str: return json.dumps(obj, ensure_ascii=False, default=str) +def _redacted_tool_result_string(value: Any) -> str: + redacted = redact_tool_result_file_content(value) + if isinstance(redacted, str): + return redacted + return _json_dumps(redacted) + + def serialize_user_input(user_input: str) -> str: """Serialize a plain user input string to gen_ai.input.messages JSON.""" return _json_dumps([{"role": "user", "parts": [{"type": "text", "content": _truncate(user_input)}]}]) @@ -55,11 +64,14 @@ def serialize_input_messages(messages: list) -> str: } ) elif btype == "tool_result": + response = getattr(block, "text", None) + if response is None or response == "": + response = getattr(block, "content", "") or "" parts.append( { "type": "tool_call_response", "id": getattr(block, "tool_use_id", ""), - "response": _truncate(getattr(block, "text", "") or ""), + "response": _truncate(_redacted_tool_result_string(response)), } ) else: @@ -117,8 +129,8 @@ def serialize_tool_arguments(arguments: dict | Any) -> str: def serialize_tool_result(result: Any) -> str: """Serialize tool call result to JSON string (truncated).""" if isinstance(result, str): - return _truncate(result) + return _truncate(_redacted_tool_result_string(result)) content = getattr(result, "content", None) if content is not None: - return _truncate(str(content)) - return _truncate(_json_dumps(result)) + return _truncate(_redacted_tool_result_string(content)) + return _truncate(_redacted_tool_result_string(result)) diff --git a/src/iac_code/tools/base.py b/src/iac_code/tools/base.py index c7bfaf38..49fcfa27 100644 --- a/src/iac_code/tools/base.py +++ b/src/iac_code/tools/base.py @@ -34,6 +34,7 @@ class ToolContext: relative_read_directories: list[str] = field(default_factory=list) # True when this tool call is being executed as part of a pipeline step. pipeline_mode: bool = False + env_overrides: dict[str, str] = field(default_factory=dict) def __init__( self, @@ -44,6 +45,7 @@ def __init__( trusted_read_directories: list[str] | str | None = None, relative_read_directories: list[str] | None = None, pipeline_mode: bool = False, + env_overrides: dict[str, str] | None = None, ) -> None: if isinstance(tool_use_id, list) and isinstance(additional_directories, list): old_additional_directories = tool_use_id @@ -63,6 +65,7 @@ def __init__( ) self.relative_read_directories = list(relative_read_directories or []) self.pipeline_mode = pipeline_mode + self.env_overrides = {str(key): str(value) for key, value in (env_overrides or {}).items() if value is not None} @dataclass @@ -182,6 +185,16 @@ def render_tool_result_message(self, output: str, *, is_error: bool = False, ver """ return None + @property + def render_verbose_result_in_transcript(self) -> bool: + """Whether Ctrl+O transcript view may render this result verbosely. + + Normal tools keep transcript results compact to avoid dumping large file + contents. Curated pipeline tools can opt in when their verbose renderer + is intentionally bounded and formatted for transcript review. + """ + return False + def render_tool_use_error_message(self, error: str) -> str | None: """Return error text for display.""" return None diff --git a/src/iac_code/tools/result_storage.py b/src/iac_code/tools/result_storage.py index ee997372..42d64e89 100644 --- a/src/iac_code/tools/result_storage.py +++ b/src/iac_code/tools/result_storage.py @@ -14,6 +14,7 @@ DEFAULT_MAX_INLINE_CHARS = 50_000 DEFAULT_PREVIEW_CHARS = 2_000 +EXTERNALIZED_RESULT_PATH_METADATA_KEY = "_iac_code_externalized_result_path" _SAFE_TOOL_USE_ID_RE = re.compile(r"^[A-Za-z0-9_.-]+$") diff --git a/src/iac_code/tools/tool_executor.py b/src/iac_code/tools/tool_executor.py index dd29fb05..64e01cbb 100644 --- a/src/iac_code/tools/tool_executor.py +++ b/src/iac_code/tools/tool_executor.py @@ -74,6 +74,7 @@ async def _validate_and_execute(self, call: ToolCallRequest, context: ToolContex relative_read_directories=list(context.relative_read_directories), tool_use_id=call.id, pipeline_mode=context.pipeline_mode, + env_overrides=dict(context.env_overrides), ) timeout = tool.timeout if tool.timeout is not None else self._tool_timeout @@ -155,13 +156,29 @@ async def _execute_serial(self, calls: list[ToolCallRequest], context: ToolConte async def execute_batch(self, calls: list[ToolCallRequest], context: ToolContext) -> list[ToolResult]: """Execute tool calls with read/write partitioning. - 1. Partition into concurrent (read-only) and serial (write) batches - 2. Execute concurrent batch in parallel (up to max_concurrency) - 3. Execute serial batch sequentially - 4. Return results in original request order + Consecutive concurrency-safe read-only calls run in parallel. Calls that + are not concurrency-safe form ordering barriers: earlier reads finish + before the write, and later reads start after it. """ - concurrent, serial = self.partition(calls) - concurrent_results = await self._execute_concurrent(concurrent, context) - serial_results = await self._execute_serial(serial, context) - result_map = {call_id: result for call_id, result in concurrent_results + serial_results} + result_map: dict[str, ToolResult] = {} + concurrent_batch: list[ToolCallRequest] = [] + + async def flush_concurrent_batch() -> None: + nonlocal concurrent_batch + if not concurrent_batch: + return + for call_id, result in await self._execute_concurrent(concurrent_batch, context): + result_map[call_id] = result + concurrent_batch = [] + + for call in calls: + tool = self._registry.get(call.name) + if tool and tool.is_concurrency_safe(call.input): + concurrent_batch.append(call) + continue + await flush_concurrent_batch() + result = await self._validate_and_execute(call, context) + result_map[call.id] = result + + await flush_concurrent_batch() return [result_map[call.id] for call in calls] diff --git a/src/iac_code/types/stream_events.py b/src/iac_code/types/stream_events.py index f135b94f..a489701f 100644 --- a/src/iac_code/types/stream_events.py +++ b/src/iac_code/types/stream_events.py @@ -58,6 +58,7 @@ class ToolUseStartEvent: tool_use_id: str name: str + renderer_tool: Any | None = field(default=None, repr=False, compare=False) type: Literal["tool_use_start"] = "tool_use_start" diff --git a/src/iac_code/ui/components/select.py b/src/iac_code/ui/components/select.py index f09f0d4f..57c32b03 100644 --- a/src/iac_code/ui/components/select.py +++ b/src/iac_code/ui/components/select.py @@ -6,7 +6,7 @@ from enum import Enum from typing import Any, Callable -from rich.console import Group, RenderableType +from rich.console import Console, Group, RenderableType from rich.text import Text from iac_code.ui.components.search_box import SearchBox @@ -102,14 +102,12 @@ def __init__( # Public API # ------------------------------------------------------------------ - def run(self) -> Any | None: + def run(self, *, console: Console | None = None) -> Any | None: """Blocking mode: enter raw input and loop until selection or cancel.""" - from rich.console import Console - from iac_code.ui.core.in_place_render import InPlaceRenderer from iac_code.ui.core.raw_input import RawInputCapture - renderer = InPlaceRenderer(Console()) + renderer = InPlaceRenderer(console or Console()) result_holder: list[Any] = [] cancelled = [False] @@ -133,7 +131,7 @@ def on_cancel() -> None: if key_event is not None: self.handle_key(key_event) finally: - renderer.clear() + renderer.clear(clear_to_screen_end=True) if self._interrupted: raise KeyboardInterrupt diff --git a/src/iac_code/ui/core/in_place_render.py b/src/iac_code/ui/core/in_place_render.py index 506f0465..f32b5770 100644 --- a/src/iac_code/ui/core/in_place_render.py +++ b/src/iac_code/ui/core/in_place_render.py @@ -97,7 +97,7 @@ def render( out.flush() self._last_height = new_height - def clear(self) -> None: + def clear(self, *, clear_to_screen_end: bool = False) -> None: """Erase the last rendered frame. Idempotent — calling :meth:`clear` twice is a no-op. @@ -107,6 +107,8 @@ def clear(self) -> None: out = self._console.file try: self._erase_previous(out) + if clear_to_screen_end: + out.write("\x1b[J") out.flush() except OSError: pass diff --git a/src/iac_code/ui/renderer.py b/src/iac_code/ui/renderer.py index d9995d1a..08b85c0a 100644 --- a/src/iac_code/ui/renderer.py +++ b/src/iac_code/ui/renderer.py @@ -662,7 +662,7 @@ def _has_verbose_content(self, rec: _ToolCallRecord) -> bool: # Agent tools show their child tool tree in verbose only. if rec.children: return True - tool = self._tool_registry.get(rec.tool_name) + tool = self._tool_for_record(rec) if tool is None: return False if tool.render_tool_use_message(rec.tool_input, verbose=False) != tool.render_tool_use_message( @@ -678,9 +678,12 @@ def _any_segment_has_verbose(self, segments: list[_Segment]) -> bool: """True if any tool segment has content that differs between modes.""" return any(s.kind == "tool" and s.tool and self._has_verbose_content(s.tool) for s in segments) + def _tool_for_record(self, rec: _ToolCallRecord): + return rec.renderer_tool or self._tool_registry.get(rec.tool_name) + def _render_tool_header(self, rec: _ToolCallRecord) -> Text: """Render ``● ToolName(detail)`` line with optional child tool tree.""" - tool = self._tool_registry.get(rec.tool_name) + tool = self._tool_for_record(rec) # Streaming preview: if the real tool input has not arrived yet but we # already accumulated some partial JSON, try to extract opt-in fields @@ -775,7 +778,7 @@ def _render_tool_result(self, rec: _ToolCallRecord) -> Text | None: if rec.children and not self._verbose: return None - tool = self._tool_registry.get(rec.tool_name) + tool = self._tool_for_record(rec) result_text = None if tool: result_text = tool.render_tool_result_message( @@ -1241,6 +1244,7 @@ async def _rebuild_after_transcript(): tool_name=event.name, tool_input={}, start_time=time.monotonic(), + renderer_tool=event.renderer_tool, ) tool_records[event.tool_use_id] = rec segments.append(_Segment(kind="tool", tool=rec)) diff --git a/src/iac_code/ui/repl.py b/src/iac_code/ui/repl.py index f5ee4e0e..4a68ea8c 100644 --- a/src/iac_code/ui/repl.py +++ b/src/iac_code/ui/repl.py @@ -18,13 +18,16 @@ import signal import subprocess import sys +import threading import time from dataclasses import dataclass from datetime import datetime from io import StringIO +from pathlib import Path from types import ModuleType from typing import TYPE_CHECKING, Any, Literal, cast +import yaml from loguru import logger from rich.console import Console, Group from rich.live import Live @@ -80,11 +83,13 @@ ToolUseStartEvent, ) from iac_code.ui.banner import render_update_prompt_header, render_welcome_banner -from iac_code.ui.components.select import Select, SelectLayout, TextOption +from iac_code.ui.components.select import InputOption, Select, SelectLayout, TextOption +from iac_code.ui.core.in_place_render import InPlaceRenderer from iac_code.ui.core.input_history import InputHistory from iac_code.ui.core.prompt_input import PromptInput, PromptInputResult from iac_code.ui.keybindings.manager import KeyBinding, KeybindingManager from iac_code.ui.renderer import Renderer, StreamingInputBuffer +from iac_code.ui.spinner import ShimmerSpinner from iac_code.ui.suggestions.aggregator import SuggestionAggregator from iac_code.ui.suggestions.command_provider import CommandProvider from iac_code.ui.suggestions.directory_provider import DirectoryProvider @@ -101,6 +106,7 @@ from iac_code.pipeline import PipelineRunner from iac_code.pipeline.config import RunMode from iac_code.pipeline.engine.events import PipelineEvent + from iac_code.pipeline.engine.prerequisites import InstallerSpec, PrerequisiteProgress from iac_code.pipeline.engine.user_input import PipelineUserInput termios: ModuleType | None @@ -119,6 +125,309 @@ PipelineHandoffResult = Literal["not_applicable", "succeeded", "failed", "persistence_failed"] +class _BlockingPrerequisiteInterruptGuard: + """Make Ctrl+C interrupt synchronous prerequisite work immediately.""" + + def __init__(self) -> None: + self._previous_handler: Any = None + self._previous_wakeup_fd: int | None = None + self._installed = False + + def __enter__(self) -> "_BlockingPrerequisiteInterruptGuard": + if threading.current_thread() is not threading.main_thread(): + return self + try: + self._previous_wakeup_fd = signal.set_wakeup_fd(-1) + self._previous_handler = signal.getsignal(signal.SIGINT) + signal.signal(signal.SIGINT, self._raise_keyboard_interrupt) + self._installed = True + except (ValueError, OSError, RuntimeError): + self._restore_wakeup_fd() + self._previous_handler = None + self._installed = False + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + if not self._installed: + return + try: + signal.signal(signal.SIGINT, self._previous_handler) + except (ValueError, OSError, RuntimeError): + pass + self._restore_wakeup_fd() + + @staticmethod + def _raise_keyboard_interrupt(_signum: int, _frame: object) -> None: + raise KeyboardInterrupt + + def _restore_wakeup_fd(self) -> None: + if self._previous_wakeup_fd is None: + return + try: + signal.set_wakeup_fd(self._previous_wakeup_fd) + except (ValueError, OSError, RuntimeError): + pass + self._previous_wakeup_fd = None + + +class _PipelinePrerequisiteProgressDisplay: + """Bounded progress display for long prerequisite installers.""" + + _MAX_LINES = 3 + + def __init__(self, console: Console | None, *, refresh_interval: float = 0.08) -> None: + self._console = console + self._renderer: InPlaceRenderer | None = None + self._lines: list[str] = [] + self._download_progress_line: str | None = None + self._spinner = ShimmerSpinner(status=_("Installing prerequisites...")) + self._refresh_interval = refresh_interval + self._stop_refresh = threading.Event() + self._refresh_thread: threading.Thread | None = None + self._lock = threading.Lock() + + def handle(self, event: "PrerequisiteProgress") -> None: + if self._console is None: + return + should_start_refresh = False + with self._lock: + self._spinner.update_status(self._status_for_event(event)) + line = self._format_event(event) + if event.phase == "download": + self._lines = [] + if _is_download_progress_event(event): + self._download_progress_line = line or None + elif line: + self._lines.append(line) + self._lines = self._lines[-self._MAX_LINES :] + if self._renderer is None: + self._renderer = InPlaceRenderer(self._console) + should_start_refresh = True + self._renderer.render(self._render()) + if should_start_refresh: + self._start_refresh_loop() + + def close(self) -> None: + self.clear() + + def clear(self) -> None: + self._stop_refresh.set() + if self._refresh_thread is not None: + self._refresh_thread.join(timeout=0.5) + self._refresh_thread = None + with self._lock: + self._stop_refresh = threading.Event() + if self._renderer is None: + return + self._renderer.clear(clear_to_screen_end=True) + self._renderer = None + + def _format_event(self, event: "PrerequisiteProgress") -> str: + message = _clean_progress_message(event.message) + command = _format_short_command(event.command) + download_progress = _format_download_progress(event) + if download_progress: + line = download_progress + elif event.status == "output": + line = message + elif command: + status_labels = { + "started": _("Running: {command}"), + "succeeded": _("Done: {command}"), + "failed": _("Failed: {command}"), + } + line = status_labels.get(event.status, "{command}").format(command=command) + else: + line = message + width = max(40, min(getattr(self._console, "width", 100) or 100, 120) - 6) + return _truncate_progress_line(line, width) + + def _status_for_event(self, event: "PrerequisiteProgress") -> str: + if event.phase == "download": + return _("Downloading prerequisites...") + if event.phase == "post_install": + return _("Initializing prerequisites...") + installer_label = _pipeline_prerequisite_installer_label_from_progress(event) + if installer_label: + return _("Installing prerequisites with {installer}...").format(installer=installer_label) + return _("Installing prerequisites...") + + def _render(self) -> Group: + parts = [self._spinner.render()] + body = Text() + if self._download_progress_line: + body.append(" " + self._download_progress_line, style="dim") + body.append("\n") + for line in self._lines: + body.append(" " + line, style="dim") + body.append("\n") + if body.plain.endswith("\n"): + body = Text(body.plain[:-1]) + if body.plain: + parts.append(body) + return Group(*parts) + + def _start_refresh_loop(self) -> None: + if self._refresh_thread is not None: + return + self._refresh_thread = threading.Thread( + target=self._refresh_until_closed, + name="pipeline-prerequisite-progress", + daemon=True, + ) + self._refresh_thread.start() + + def _refresh_until_closed(self) -> None: + while not self._stop_refresh.wait(self._refresh_interval): + self._render_in_place() + + def _render_in_place(self) -> None: + with self._lock: + if self._renderer is not None: + self._renderer.render(self._render()) + + +def _format_short_command(command: list[str]) -> str: + if not command: + return "" + if len(command) <= 3: + return " ".join(command) + return " ".join(command[:3]) + " ..." + + +def _clean_progress_message(message: str) -> str: + return " ".join(message.strip().split()) + + +def _truncate_progress_line(line: str, width: int) -> str: + if len(line) <= width: + return line + return line[: max(0, width - 4)].rstrip() + " ..." + + +def _format_download_progress(event: "PrerequisiteProgress") -> str: + if not _is_download_progress_event(event): + return "" + downloaded_bytes = event.downloaded_bytes + if downloaded_bytes is None: + return "" + downloaded = _format_progress_bytes(downloaded_bytes) + if event.total_bytes and event.total_bytes > 0: + return _("Download: {percent} ({downloaded} / {total})").format( + percent=_format_progress_percent(downloaded_bytes, event.total_bytes), + downloaded=downloaded, + total=_format_progress_bytes(event.total_bytes), + ) + return _("Download: {downloaded} downloaded").format(downloaded=downloaded) + + +def _is_download_progress_event(event: "PrerequisiteProgress") -> bool: + return event.phase == "download" and event.status == "output" and event.downloaded_bytes is not None + + +def _format_progress_bytes(size: int) -> str: + value = float(size) + for unit in ("B", "KB", "MB", "GB"): + if value < 1024 or unit == "GB": + if unit == "B": + return f"{int(value)} {unit}" + return f"{value:.1f} {unit}" + value /= 1024 + return f"{int(value)} B" + + +def _format_progress_percent(downloaded: int, total: int) -> str: + percent = max(0.0, min(100.0, downloaded / total * 100)) + if 0 < percent < 1: + return "<1%" + return f"{percent:.0f}%" + + +def _format_prerequisite_failure_reason(reason: str) -> str: + lines = [line.strip() for line in reason.splitlines() if line.strip()] + if not lines: + return reason.strip() + if len(lines) <= 4: + return "\n".join(lines) + return "\n".join([*lines[:4], "..."]) + + +def _resolve_feature_flags(raw: object) -> dict[str, bool]: + from iac_code.pipeline.engine.loader import _resolve_feature_flags as resolve + + return resolve(raw if isinstance(raw, dict) else None) + + +def _pipeline_feature_label_from_key(key: str) -> str | None: + labels = { + "review_step": _("review step"), + } + return labels.get(key) + + +def _pipeline_installer_label_from_key(key: str) -> str | None: + labels = { + "direct_binary": _("Direct binary download"), + "direct_binary_download": _("Direct binary download"), + "go_install": _("Go install"), + "homebrew": _("Homebrew"), + } + return labels.get(key) + + +def _pipeline_prerequisite_installer_label(installer: "InstallerSpec") -> str: + raw_key = getattr(installer, "display_key", None) + if isinstance(raw_key, str) and raw_key: + label = _pipeline_installer_label_from_key(raw_key) + if label: + return label + raw_name = getattr(installer, "display_name", None) + if isinstance(raw_name, str) and raw_name: + return raw_name + label = _pipeline_installer_label_from_key(str(getattr(installer, "id", "")).replace("-", "_")) + return label or str(getattr(installer, "id", "")) + + +def _pipeline_prerequisite_installer_label_from_progress(event: "PrerequisiteProgress") -> str: + raw_key = getattr(event, "installer_display_key", None) + if isinstance(raw_key, str) and raw_key: + label = _pipeline_installer_label_from_key(raw_key) + if label: + return label + raw_name = getattr(event, "installer_display_name", None) + if isinstance(raw_name, str) and raw_name: + return raw_name + installer_id = str(getattr(event, "installer_id", "") or "") + label = _pipeline_installer_label_from_key(installer_id.replace("-", "_")) + return label or installer_id + + +def _pipeline_prerequisite_feature_labels(raw_flags: object) -> dict[str, str]: + if not isinstance(raw_flags, dict): + return {} + labels: dict[str, str] = {} + for flag_name, raw_spec in raw_flags.items(): + if not isinstance(flag_name, str) or not isinstance(raw_spec, dict): + continue + spec = cast(dict[str, Any], raw_spec) + raw_key = spec.get("display_key") or spec.get("label_key") + if isinstance(raw_key, str) and raw_key: + label = _pipeline_feature_label_from_key(raw_key) + if label: + labels[flag_name] = label + continue + raw_label = spec.get("display_name") or spec.get("label") + if isinstance(raw_label, str) and raw_label: + labels[flag_name] = raw_label + return labels + + +def prepare_prerequisites(*args: Any, **kwargs: Any) -> Any: + from iac_code.pipeline.engine.prerequisites import prepare_prerequisites as prepare + + return prepare(*args, **kwargs) + + class ExitREPLError(Exception): """Raised by /exit command to break the REPL loop.""" @@ -2728,6 +3037,181 @@ def _record_pipeline_display_user_aborted(self) -> None: except Exception as exc: logger.warning("Failed to record pipeline display user abort: {}", exc) + def _sidecar_prerequisite_metadata(self, *, cwd: str, session_id: str) -> dict[str, Any] | None: + try: + raw_session_dir = self._session_storage.session_dir(cwd, session_id) + meta_path = Path(raw_session_dir) / "pipeline" / "meta.yaml" + if not meta_path.exists(): + return None + raw = yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {} + except Exception: + logger.debug("Failed to peek pipeline sidecar prerequisites", exc_info=True) + return None + if not isinstance(raw, dict): + return None + metadata = raw.get("prerequisites") + return dict(metadata) if isinstance(metadata, dict) else None + + @staticmethod + def _apply_pipeline_prerequisite_env_overrides(metadata: dict[str, Any]) -> None: + # Prerequisite environment belongs to pipeline tool execution only. Keep this + # compatibility hook as a no-op so older tests/mocks can still bind it. + return None + + def _load_pipeline_raw_config(self, pipeline_name: str) -> dict[str, Any]: + from iac_code.pipeline import discover_pipelines + + pipeline_dir = discover_pipelines().get(pipeline_name) + if pipeline_dir is None: + return {} + raw = yaml.safe_load((pipeline_dir / "pipeline.yaml").read_text(encoding="utf-8")) or {} + return raw if isinstance(raw, dict) else {} + + def _prepare_pipeline_prerequisite_metadata( + self, + *, + pipeline_name: str, + cwd: str, + session_id: str, + allow_sidecar_metadata: bool = False, + ) -> dict[str, Any]: + if allow_sidecar_metadata: + sidecar_metadata = self._sidecar_prerequisite_metadata(cwd=cwd, session_id=session_id) + if sidecar_metadata is not None: + self._apply_pipeline_prerequisite_env_overrides(sidecar_metadata) + return sidecar_metadata + + raw = self._load_pipeline_raw_config(pipeline_name) + raw_prerequisites = raw.get("prerequisites") or {} + if not isinstance(raw_prerequisites, dict): + raw_prerequisites = {} + + feature_flags = _resolve_feature_flags(raw.get("feature_flags")) + self._pipeline_prerequisite_feature_labels_by_flag = _pipeline_prerequisite_feature_labels( + raw.get("feature_flags") + ) + self._pipeline_prerequisite_required_flags_by_name = { + str(name): [str(flag) for flag in (raw_prerequisite.get("required_by_flags") or []) if flag] + for name, raw_prerequisite in raw_prerequisites.items() + if isinstance(raw_prerequisite, dict) + } + progress_display = self._make_pipeline_prerequisite_progress_display() + + def choose_installer(name: str, installers: list[InstallerSpec]) -> str | None: + progress_display.clear() + return self._pipeline_prerequisite_choice(name, installers) + + try: + with _BlockingPrerequisiteInterruptGuard(): + resolution = prepare_prerequisites( + raw_prerequisites, + feature_flags=feature_flags, + surface="repl", + choose_installer=choose_installer, + progress_handler=progress_display.handle, + ) + finally: + progress_display.close() + metadata = resolution.to_metadata() + self._apply_pipeline_prerequisite_env_overrides(metadata) + self._print_pipeline_prerequisite_status_messages(metadata) + return metadata + + def _make_pipeline_prerequisite_progress_display(self) -> _PipelinePrerequisiteProgressDisplay: + console = getattr(self, "console", None) + return _PipelinePrerequisiteProgressDisplay(console if isinstance(console, Console) else None) + + def _pipeline_prerequisite_choice(self, name: str, installers: list[InstallerSpec]) -> str | None: + if not installers: + self.renderer.print_system_message( + _( + "Pipeline prerequisite {name} is missing and no configured installer is usable; " + "{feature} will be skipped for this run." + ).format(feature=self._pipeline_prerequisite_feature_label(name), name=name), + style="yellow", + ) + return None + + feature_label = self._pipeline_prerequisite_feature_label(name) + self.renderer.print_system_message( + _( + "Pipeline prerequisite {name} is missing; it is required for {feature}. " + "Choose an installer or skip this feature for this run." + ).format(feature=feature_label, name=name), + style="yellow", + ) + options: list[TextOption | InputOption] = [ + TextOption( + label=_pipeline_prerequisite_installer_label(installer), + value=installer.id, + ) + for installer in installers + ] + options.append(TextOption(label=_("Skip {feature} for this run").format(feature=feature_label), value="skip")) + selection = Select(options, default_value="skip", layout=SelectLayout.EXPANDED).run( + console=getattr(self, "console", None) + ) + return None if selection in (None, "skip") else str(selection) + + def _print_pipeline_prerequisite_status_messages(self, metadata: dict[str, Any]) -> None: + decisions = metadata.get("decisions") + feature_flags = metadata.get("feature_flags") + if not isinstance(decisions, dict) or not isinstance(feature_flags, dict): + return + + warning_statuses = { + "declined_or_unavailable", + "disabled_feature", + "install_failed", + "missing_after_install", + "post_install_failed", + } + for name, raw_decision in decisions.items(): + if not isinstance(raw_decision, dict): + continue + status = str(raw_decision.get("status") or "") + if status not in warning_statuses: + continue + required_flags = [str(flag) for flag in raw_decision.get("required_flags") or [] if flag] + disabled_flags = [flag for flag in required_flags if feature_flags.get(flag) is False] + if not disabled_flags: + continue + feature_name = self._pipeline_prerequisite_feature_name(disabled_flags) + reason = str(raw_decision.get("message") or status.replace("_", " ")) + summary = _( + "{feature} skipped\n" + "Prerequisite {name} failed, so {feature} is disabled for this run.\n" + "Reason: {reason}" + ).format( + feature=feature_name, + name=name, + reason=_format_prerequisite_failure_reason(reason), + ) + self.renderer.print_system_message( + summary, + style="yellow", + ) + console = getattr(self, "console", None) + if console is not None: + console.print() + + def _pipeline_prerequisite_feature_name(self, required_flags: list[str]) -> str: + labels_by_flag = getattr(self, "_pipeline_prerequisite_feature_labels_by_flag", {}) + labels: list[str] = [] + if isinstance(labels_by_flag, dict): + for flag in required_flags: + label = labels_by_flag.get(flag) + if isinstance(label, str) and label and label not in labels: + labels.append(label) + if labels: + return ", ".join(labels) + return _("configured feature") + + def _pipeline_prerequisite_feature_label(self, prerequisite_name: str) -> str: + flags_by_name = getattr(self, "_pipeline_prerequisite_required_flags_by_name", {}) + required_flags = flags_by_name.get(prerequisite_name, []) if isinstance(flags_by_name, dict) else [] + return self._pipeline_prerequisite_feature_name([str(flag) for flag in required_flags]) + async def ensure_pipeline_restored_for_prompt(self) -> bool: """Restore a resumable pipeline sidecar so /prompt can inspect the real AgentLoop context.""" from iac_code.pipeline import create_pipeline @@ -2741,9 +3225,16 @@ async def ensure_pipeline_restored_for_prompt(self) -> bool: pipeline_cwd = get_working_directory() or self._original_cwd if not self._detect_pipeline_session(pipeline_cwd, self._session_id): return False + pipeline_name = get_pipeline_name() + prerequisite_resolution = self._prepare_pipeline_prerequisite_metadata( + pipeline_name=pipeline_name, + cwd=pipeline_cwd, + session_id=self._session_id, + allow_sidecar_metadata=True, + ) self._pipeline = create_pipeline( - name=get_pipeline_name(), + name=pipeline_name, provider_manager=self._provider_manager, base_tool_registry=self.tool_registry, session_storage=self._session_storage, @@ -2753,6 +3244,7 @@ async def ensure_pipeline_restored_for_prompt(self) -> bool: memory_content_getter=self._pipeline_memory_content_getter(), auto_trigger_skills=self.command_registry.get_model_invocable_skills(), resume_from_sidecar=True, + prerequisite_resolution=prerequisite_resolution, ) self._refresh_pipeline_display_recorder() restored = self._pipeline.sidecar_restore_result @@ -2821,8 +3313,16 @@ async def _handle_pipeline_chat(self, user_input: str | "PipelineUserInput") -> if self._pipeline is None: pipeline_cwd = get_working_directory() or self._original_cwd self._prepare_pipeline_session_layout(pipeline_cwd, self._session_id) + pipeline_name = get_pipeline_name() + has_resumable_sidecar = self._detect_pipeline_session(pipeline_cwd, self._session_id) + prerequisite_resolution = self._prepare_pipeline_prerequisite_metadata( + pipeline_name=pipeline_name, + cwd=pipeline_cwd, + session_id=self._session_id, + allow_sidecar_metadata=has_resumable_sidecar, + ) self._pipeline = create_pipeline( - name=get_pipeline_name(), + name=pipeline_name, provider_manager=self._provider_manager, base_tool_registry=self.tool_registry, session_storage=self._session_storage, @@ -2831,11 +3331,12 @@ async def _handle_pipeline_chat(self, user_input: str | "PipelineUserInput") -> permission_context_getter=lambda: self.store.get_state().permission_context, memory_content_getter=self._pipeline_memory_content_getter(), auto_trigger_skills=self.command_registry.get_model_invocable_skills(), + prerequisite_resolution=prerequisite_resolution, ) self._refresh_pipeline_display_recorder() self._pipeline_backup_blocked = False restored = None - if self._detect_pipeline_session(pipeline_cwd, self._session_id): + if has_resumable_sidecar: restored = await self._pipeline.restore_from_sidecar() if restored.ok is False: detail = restored.reason or restored.status @@ -5231,8 +5732,15 @@ async def swap_session_async(self, new_session_id: str) -> None: if choice == "resume": from iac_code.pipeline import create_pipeline + pipeline_name = get_pipeline_name() + prerequisite_resolution = self._prepare_pipeline_prerequisite_metadata( + pipeline_name=pipeline_name, + cwd=pipeline_cwd, + session_id=new_session_id, + allow_sidecar_metadata=True, + ) self._pipeline = create_pipeline( - name=get_pipeline_name(), + name=pipeline_name, provider_manager=self._provider_manager, base_tool_registry=self.tool_registry, session_storage=self._session_storage, @@ -5242,6 +5750,7 @@ async def swap_session_async(self, new_session_id: str) -> None: memory_content_getter=self._pipeline_memory_content_getter(), auto_trigger_skills=self.command_registry.get_model_invocable_skills(), resume_from_sidecar=True, + prerequisite_resolution=prerequisite_resolution, ) restored = self._pipeline.sidecar_restore_result if restored is None or restored.ok is False: @@ -5274,7 +5783,7 @@ async def _confirm_pipeline_resume(self, meta_path) -> str: import yaml as _yaml from iac_code.pipeline.display_names import display_step_name - from iac_code.ui.components.select import InputOption, Select, SelectLayout, TextOption + from iac_code.ui.components.select import Select, SelectLayout, TextOption try: loaded = _yaml.safe_load(meta_path.read_text(encoding="utf-8")) diff --git a/src/iac_code/ui/stream_accumulator.py b/src/iac_code/ui/stream_accumulator.py index 5b4aeadb..7e65b605 100644 --- a/src/iac_code/ui/stream_accumulator.py +++ b/src/iac_code/ui/stream_accumulator.py @@ -48,6 +48,7 @@ class ToolCallRecord: children: list[SubAgentChild] | None = None start_time: float = 0.0 progress_renderable: Any = None + renderer_tool: Any = None @dataclass @@ -115,6 +116,7 @@ def process(self, event: StreamEvent) -> str: tool_name=event.name, tool_input={}, start_time=time.monotonic(), + renderer_tool=event.renderer_tool, ) self.tool_records[event.tool_use_id] = rec self.segments.append(RenderSegment(kind="tool", tool=rec)) diff --git a/src/iac_code/ui/transcript_view.py b/src/iac_code/ui/transcript_view.py index 5b1c1a0d..46fa81c1 100644 --- a/src/iac_code/ui/transcript_view.py +++ b/src/iac_code/ui/transcript_view.py @@ -139,11 +139,29 @@ def _render_tool(self, rec: "_ToolCallRecord") -> None: for line in header_lines[1:]: self._console.print(line) - # Result stays compact so we never dump full file contents. - result_line = r._render_tool_result(rec) + # Most tool results stay compact so we never dump full file contents. + # Pipeline-only tools can carry their real Tool renderer on the record; + # if that renderer has curated verbose output, use it generically. + if self._should_render_result_verbose(rec): + saved = r._verbose + r._verbose = True + try: + result_line = r._render_tool_result(rec) + finally: + r._verbose = saved + else: + result_line = r._render_tool_result(rec) if result_line: self._console.print(result_line) + def _should_render_result_verbose(self, rec: "_ToolCallRecord") -> bool: + renderer_tool = getattr(rec, "renderer_tool", None) + if renderer_tool is None: + return False + if not bool(getattr(renderer_tool, "render_verbose_result_in_transcript", False)): + return False + return bool(self._renderer._has_verbose_content(rec)) + def _render_subagent_prompt(self, rec: "_ToolCallRecord") -> None: """For agent-style tools, print the prompt handed to the subagent.""" prompt = "" diff --git a/src/iac_code/utils/tool_result_redaction.py b/src/iac_code/utils/tool_result_redaction.py new file mode 100644 index 00000000..52882cc4 --- /dev/null +++ b/src/iac_code/utils/tool_result_redaction.py @@ -0,0 +1,93 @@ +"""Helpers for redacting sensitive tool-result payload fields.""" + +from __future__ import annotations + +import json +import re +from typing import Any + +REDACTED_TOOL_RESULT_VALUE = "[REDACTED]" +_FILE_CONTENT_KEYS = {"file_content", "fileContent"} +_FILE_CONTENT_STRING_FIELD_RE = re.compile(r'("(?:file_content|fileContent)"\s*:\s*)"') +_TRUNCATED_RESULT_MARKER = "\n\n... [truncated" + + +def is_file_content_key(key: Any) -> bool: + return isinstance(key, str) and key in _FILE_CONTENT_KEYS + + +def redact_file_content_fields(value: Any) -> Any: + if isinstance(value, dict): + return { + key: REDACTED_TOOL_RESULT_VALUE if is_file_content_key(key) else redact_file_content_fields(item) + for key, item in value.items() + } + if isinstance(value, list): + return [redact_file_content_fields(item) for item in value] + if isinstance(value, tuple): + return [redact_file_content_fields(item) for item in value] + return value + + +def redact_file_content_from_json_string(value: str) -> str: + if not any(key in value for key in _FILE_CONTENT_KEYS): + return value + try: + parsed = json.loads(value) + except (TypeError, ValueError): + return _redact_file_content_string_literals(value) + redacted = redact_file_content_fields(parsed) + if redacted == parsed: + return value + return json.dumps(redacted, ensure_ascii=False) + + +def _redact_file_content_string_literals(value: str) -> str: + parts: list[str] = [] + pos = 0 + changed = False + + while True: + match = _FILE_CONTENT_STRING_FIELD_RE.search(value, pos) + if match is None: + parts.append(value[pos:]) + break + + parts.append(value[pos : match.start()]) + parts.append(match.group(1)) + parts.append('"') + parts.append(REDACTED_TOOL_RESULT_VALUE) + parts.append('"') + changed = True + + value_start = match.end() + value_end = _json_string_end(value, value_start) + if value_end is None: + marker_index = value.find(_TRUNCATED_RESULT_MARKER, value_start) + if marker_index >= 0: + parts.append(value[marker_index:]) + break + pos = value_end + + return "".join(parts) if changed else value + + +def _json_string_end(value: str, start: int) -> int | None: + escaped = False + for index in range(start, len(value)): + char = value[index] + if escaped: + escaped = False + continue + if char == "\\": + escaped = True + continue + if char == '"': + return index + 1 + return None + + +def redact_tool_result_file_content(value: Any) -> Any: + if isinstance(value, str): + return redact_file_content_from_json_string(value) + return redact_file_content_fields(value) diff --git a/tests/a2a/test_artifacts.py b/tests/a2a/test_artifacts.py index d61b3e8b..34f14201 100644 --- a/tests/a2a/test_artifacts.py +++ b/tests/a2a/test_artifacts.py @@ -264,6 +264,29 @@ def test_sanitize_public_artifact_payload_keys_are_case_insensitive() -> None: assert artifact == {"filename": "result.txt", "metadata": {"label": "safe"}} +def test_sanitize_public_artifact_data_redacts_file_content_metadata() -> None: + artifact = sanitize_public_artifact_data( + { + "filename": "template.yaml", + "metadata": { + "file_content": "SECRET_TEMPLATE", + "fileContent": "SECRET_TEMPLATE_CAMEL", + "nested": {"file_content": "NESTED_SECRET_TEMPLATE"}, + }, + } + ) + + assert artifact == { + "filename": "template.yaml", + "metadata": { + "file_content": "[REDACTED]", + "fileContent": "[REDACTED]", + "nested": {"file_content": "[REDACTED]"}, + }, + } + assert "SECRET_TEMPLATE" not in str(artifact) + + def test_sanitize_public_tool_output_handles_artifacts_containers_and_sensitive_keys() -> None: output = sanitize_public_tool_output_data( { @@ -288,6 +311,26 @@ def test_sanitize_public_tool_output_handles_artifacts_containers_and_sensitive_ } +def test_sanitize_public_tool_output_redacts_artifact_file_content_metadata() -> None: + output = sanitize_public_tool_output_data( + { + "artifact": { + "filename": "template.yaml", + "content": "artifact payload should be omitted", + "metadata": {"file_content": "SECRET_TEMPLATE"}, + } + } + ) + + assert output == { + "artifact": { + "filename": "template.yaml", + "metadata": {"file_content": "[REDACTED]"}, + } + } + assert "SECRET_TEMPLATE" not in str(output) + + def test_sanitize_public_tool_output_relativizes_paths_under_public_roots() -> None: output = sanitize_public_tool_output_data( "STDOUT:\n" diff --git a/tests/a2a/test_pipeline_events.py b/tests/a2a/test_pipeline_events.py index d869e220..64cdf871 100644 --- a/tests/a2a/test_pipeline_events.py +++ b/tests/a2a/test_pipeline_events.py @@ -8,7 +8,9 @@ from iac_code.a2a.pipeline_events import PIPELINE_EVENTS_EXTENSION_URI, PipelineA2AContext, PipelineEventTranslator from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.pipeline.engine.step_spec import A2AArtifactSpec from iac_code.services.permissions.audit import fingerprint_text +from iac_code.tools.result_storage import ResultStorage from iac_code.types.stream_events import ( CandidateDetailEvent, DiagramEvent, @@ -93,6 +95,72 @@ def test_mcp_progress_event_has_tool_progress_envelope() -> None: assert envelope["data"]["message"] == "halfway" +def test_tool_result_redacts_embedded_infraguard_file_content() -> None: + translator = PipelineEventTranslator(_ctx()) + raw_template = "ROSTemplateFormatVersion: '2015-09-01'\nResources: {}\n" + result = json.dumps( + { + "file_path": "templates/demo.yml", + "file_sha256": "sha256-value", + "file_content": raw_template, + "passed": True, + }, + ensure_ascii=False, + ) + + [envelope] = translator.translate( + ToolResultEvent( + tool_use_id="toolu-1", + tool_name="infraguard_scan", + result=result, + is_error=False, + ) + ) + + rendered = json.dumps(envelope, ensure_ascii=False) + assert "ROSTemplateFormatVersion" not in rendered + assert "file_content" in rendered + assert "sha256-value" in rendered + + +def test_tool_result_redacts_externalized_infraguard_file_content_preview(tmp_path) -> None: + translator = PipelineEventTranslator(_ctx()) + raw_template = "ROSTemplateFormatVersion: '2015-09-01'\nResources: {}\n" + ("X" * 500) + raw_result = json.dumps( + { + "file_path": "templates/demo.yml", + "file_sha256": "sha256-value", + "file_content": raw_template, + "passed": True, + }, + ensure_ascii=False, + ) + preview = ( + ResultStorage( + storage_dir=str(tmp_path / "tool-results"), + max_inline_chars=10, + preview_chars=180, + ) + .process("toolu-1", raw_result) + .content + ) + assert "ROSTemplateFormatVersion" in preview + + [envelope] = translator.translate( + ToolResultEvent( + tool_use_id="toolu-1", + tool_name="infraguard_scan", + result=preview, + is_error=False, + ) + ) + + rendered = json.dumps(envelope, ensure_ascii=False) + assert "ROSTemplateFormatVersion" not in rendered + assert "file_content" in rendered + assert "sha256-value" in rendered + + def test_pipeline_warning_translates_to_non_terminal_envelope() -> None: translator = PipelineEventTranslator(_ctx()) @@ -791,6 +859,75 @@ def test_completion_artifact_windows_path_does_not_leak_in_filename() -> None: assert ".iac-code" not in rendered +def test_completion_artifact_includes_role_and_resolved_supersedes_path_from_spec() -> None: + context = _ctx() + context.a2a_artifacts_by_step_id = { + "reviewing": [ + A2AArtifactSpec( + path="conclusion.file_path", + content="conclusion.content", + role="final", + supersedes_path="conclusion.file_path", + ) + ] + } + translator = PipelineEventTranslator(context) + + envelopes = translator.translate( + PipelineEvent( + type=PipelineEventType.SUB_STEP_COMPLETED, + step_id=None, + timestamp=time.time(), + data={ + "sub_pipeline_id": "evaluate_candidate_abcd", + "candidate_index": 0, + "step_id": "reviewing", + "conclusion_field": "review", + "conclusion": { + "file_path": "templates/main.yaml", + "content": "reviewed ROSTemplate", + }, + }, + ) + ) + + artifact = envelopes[1]["artifact"] + assert artifact["role"] == "final" + assert artifact["supersedesPath"] == "templates/main.yaml" + assert artifact["supersedesKey"] == fingerprint_text("templates/main.yaml") + + +def test_completion_artifact_defaults_to_final_and_supersedes_its_own_path() -> None: + context = _ctx() + context.a2a_artifacts_by_step_id = { + "template_generating": [{"path": "conclusion.file_path", "content": "conclusion.content"}] + } + translator = PipelineEventTranslator(context) + + envelopes = translator.translate( + PipelineEvent( + type=PipelineEventType.SUB_STEP_COMPLETED, + step_id=None, + timestamp=time.time(), + data={ + "sub_pipeline_id": "evaluate_candidate_abcd", + "candidate_index": 0, + "step_id": "template_generating", + "conclusion_field": "template", + "conclusion": { + "file_path": "templates/generated.yaml", + "content": "generated ROSTemplate", + }, + }, + ) + ) + + artifact = envelopes[1]["artifact"] + assert artifact["role"] == "final" + assert artifact["supersedesPath"] == "templates/generated.yaml" + assert artifact["supersedesKey"] == fingerprint_text("templates/generated.yaml") + + def test_candidate_step_failure_keeps_global_task_status_working() -> None: translator = PipelineEventTranslator(_ctx()) translator.translate( diff --git a/tests/a2a/test_pipeline_executor.py b/tests/a2a/test_pipeline_executor.py index 5604a0b3..6ec730ea 100644 --- a/tests/a2a/test_pipeline_executor.py +++ b/tests/a2a/test_pipeline_executor.py @@ -25,6 +25,7 @@ from iac_code.pipeline.engine.cleanup import CleanupLedger, CleanupResource from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType from iac_code.pipeline.engine.interrupt import InterruptVerdict +from iac_code.pipeline.engine.prerequisites import PrerequisiteDecision, PrerequisiteResolution from iac_code.pipeline.engine.user_input import PipelineUserInput, normalize_pipeline_user_input from iac_code.services.session_backup import BackupReason, BackupResult, SessionBackupBlocked from iac_code.services.session_layout import UnsupportedSessionLayoutError @@ -39,6 +40,41 @@ _A2A_ASYNC_TEST_TIMEOUT = 5 +def _write_pipeline_yaml(pipeline_dir: Path) -> None: + pipeline_dir.mkdir(parents=True) + (pipeline_dir / "pipeline.yaml").write_text( + json.dumps( + { + "name": "test-pipeline", + "feature_flags": {"reviewing": {"default": True}}, + "prerequisites": { + "infraguard": { + "command": "infraguard", + "required_by_flags": ["reviewing"], + "on_missing": {"non_interactive": "disable_feature"}, + } + }, + } + ), + encoding="utf-8", + ) + + +def _pipeline_executor(): + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor + + return IacCodeA2APipelineExecutor( + task_store=MagicMock(), + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + def test_active_sidecar_mismatch_error_exposes_jsonrpc_data() -> None: from iac_code.a2a.pipeline_executor import _active_sidecar_mismatch_error @@ -57,6 +93,162 @@ def test_active_sidecar_mismatch_error_exposes_jsonrpc_data() -> None: assert "task-owner" in error.message +def test_create_pipeline_inspects_prerequisites_for_fresh_a2a_run( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a import pipeline_executor as pipeline_executor_module + + pipeline_dir = tmp_path / "pipeline-def" + _write_pipeline_yaml(pipeline_dir) + resolution = PrerequisiteResolution( + feature_flags={"reviewing": False}, + decisions={ + "infraguard": PrerequisiteDecision( + name="infraguard", + command="infraguard", + status="disabled_feature", + required_flags=["reviewing"], + ) + }, + ) + inspect_calls = [] + create_kwargs = {} + + def fake_inspect(raw_prerequisites, *, feature_flags): + inspect_calls.append({"raw_prerequisites": raw_prerequisites, "feature_flags": feature_flags}) + return resolution + + def fake_create_pipeline(*args, **kwargs): + create_kwargs.update(kwargs) + return object() + + monkeypatch.setattr(pipeline_executor_module, "discover_pipelines", lambda: {"test-pipeline": pipeline_dir}) + monkeypatch.setattr(pipeline_executor_module, "get_pipeline_name", lambda: "test-pipeline") + monkeypatch.setattr(pipeline_executor_module, "inspect_prerequisites", fake_inspect, raising=False) + monkeypatch.setattr(pipeline_executor_module, "create_pipeline", fake_create_pipeline) + + _pipeline_executor()._create_pipeline( + session_id="session-1", + cwd=str(tmp_path), + runtime=_fake_runtime(), + session_storage=MagicMock(), + resume_from_sidecar=False, + ) + + assert inspect_calls == [ + { + "raw_prerequisites": { + "infraguard": { + "command": "infraguard", + "required_by_flags": ["reviewing"], + "on_missing": {"non_interactive": "disable_feature"}, + } + }, + "feature_flags": {"reviewing": True}, + } + ] + assert create_kwargs["surface"] == "a2a" + assert create_kwargs["prerequisite_resolution"] == resolution.to_metadata() + + +def test_create_pipeline_resume_sidecar_prerequisites_skip_a2a_inspection( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a import pipeline_executor as pipeline_executor_module + + pipeline_dir = tmp_path / "pipeline-def" + _write_pipeline_yaml(pipeline_dir) + session_root = tmp_path / "session-1" + sidecar = session_root / "pipeline" + sidecar.mkdir(parents=True) + stored_prerequisites = { + "feature_flags": {"reviewing": False}, + "decisions": {}, + "env_overrides": {"PATH": "/tmp/iac-code-infraguard/bin"}, + } + (sidecar / "meta.yaml").write_text( + json.dumps( + { + "status": "running", + "updated_at": 0.0, + "prerequisites": stored_prerequisites, + } + ), + encoding="utf-8", + ) + create_kwargs = {} + session_storage = MagicMock() + session_storage.session_dir.return_value = session_root + + def fail_inspect(*args, **kwargs): + raise AssertionError("resume with stored prerequisites must not inspect again") + + def fake_create_pipeline(*args, **kwargs): + create_kwargs.update(kwargs) + return object() + + monkeypatch.setattr(pipeline_executor_module, "discover_pipelines", lambda: {"test-pipeline": pipeline_dir}) + monkeypatch.setattr(pipeline_executor_module, "get_pipeline_name", lambda: "test-pipeline") + monkeypatch.setattr(pipeline_executor_module, "inspect_prerequisites", fail_inspect, raising=False) + monkeypatch.setattr(pipeline_executor_module, "create_pipeline", fake_create_pipeline) + + _pipeline_executor()._create_pipeline( + session_id="session-1", + cwd=str(tmp_path), + runtime=_fake_runtime(), + session_storage=session_storage, + resume_from_sidecar=True, + ) + + assert create_kwargs["resume_from_sidecar"] is True + assert create_kwargs["prerequisite_resolution"] == stored_prerequisites + + +def test_create_pipeline_resume_empty_sidecar_prerequisites_skip_a2a_inspection( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a import pipeline_executor as pipeline_executor_module + + pipeline_dir = tmp_path / "pipeline-def" + _write_pipeline_yaml(pipeline_dir) + session_root = tmp_path / "session-1" + sidecar = session_root / "pipeline" + sidecar.mkdir(parents=True) + (sidecar / "meta.yaml").write_text( + json.dumps({"status": "running", "updated_at": 0.0, "prerequisites": {}}), + encoding="utf-8", + ) + create_kwargs = {} + session_storage = MagicMock() + session_storage.session_dir.return_value = session_root + + def fail_inspect(*args, **kwargs): + raise AssertionError("empty sidecar prerequisites should still win") + + def fake_create_pipeline(*args, **kwargs): + create_kwargs.update(kwargs) + return object() + + monkeypatch.setattr(pipeline_executor_module, "discover_pipelines", lambda: {"test-pipeline": pipeline_dir}) + monkeypatch.setattr(pipeline_executor_module, "get_pipeline_name", lambda: "test-pipeline") + monkeypatch.setattr(pipeline_executor_module, "inspect_prerequisites", fail_inspect, raising=False) + monkeypatch.setattr(pipeline_executor_module, "create_pipeline", fake_create_pipeline) + + _pipeline_executor()._create_pipeline( + session_id="session-1", + cwd=str(tmp_path), + runtime=_fake_runtime(), + session_storage=session_storage, + resume_from_sidecar=True, + ) + + assert create_kwargs["resume_from_sidecar"] is True + assert create_kwargs["prerequisite_resolution"] == {} + + def test_active_sidecar_mismatch_error_serializes_raw_jsonrpc_data() -> None: from iac_code.a2a.jsonrpc_passthrough import install_jsonrpc_error_data_passthrough from iac_code.a2a.pipeline_executor import _active_sidecar_mismatch_error @@ -2722,6 +2914,8 @@ async def test_executor_replaces_terminal_restored_pipeline_when_sidecar_owner_m event_type: str, event_status: str, ) -> None: + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" old_event = { @@ -2755,6 +2949,20 @@ async def run(self, prompt: str): restored_pipeline = RestoredMemoryPipeline([], session_dir=session_dir) restored_pipeline.sidecar_status = sidecar_status + restored_pipeline._loaded = SimpleNamespace( + steps=[], + sub_pipelines={ + "evaluate_candidate": SimpleNamespace( + steps=[ + SimpleNamespace( + step_id="template_generating", + a2a_artifacts=[{"role": "final", "supersedesPath": "conclusion.file_path"}], + ), + SimpleNamespace(step_id="cost_estimating", a2a_artifacts=[]), + ] + ) + }, + ) fresh_pipeline = FakePipeline( [ TextDeltaEvent(text="fresh output"), @@ -2762,14 +2970,41 @@ async def run(self, prompt: str): ], session_dir=session_dir, ) + fresh_pipeline._loaded = SimpleNamespace( + steps=[], + sub_pipelines={ + "evaluate_candidate": SimpleNamespace( + steps=[ + SimpleNamespace( + step_id="template_generating", + a2a_artifacts=[{"role": "intermediate", "supersedesPath": "conclusion.file_path"}], + ), + SimpleNamespace( + step_id="reviewing", + a2a_artifacts=[{"role": "final", "supersedesPath": "conclusion.file_path"}], + ), + SimpleNamespace(step_id="cost_estimating", a2a_artifacts=[]), + ] + ) + }, + ) create_resume_flags: list[bool | None] = [] + publisher_contexts = [] def fake_create_pipeline(*args, **kwargs): create_resume_flags.append(kwargs.get("resume_from_sidecar")) return restored_pipeline if len(create_resume_flags) == 1 else fresh_pipeline + original_publisher = IacCodeA2APipelineExecutor._publisher + + def spy_publisher(self, *args, **kwargs): + publisher = original_publisher(self, *args, **kwargs) + publisher_contexts.append(publisher.translator.context) + return publisher + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", fake_create_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + monkeypatch.setattr(IacCodeA2APipelineExecutor, "_publisher", spy_publisher) store = A2ATaskStore(metrics=NoOpA2AMetrics()) executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") @@ -2790,6 +3025,12 @@ def fake_create_pipeline(*args, **kwargs): assert fresh_pipeline.run_prompts == ["new request"] record = await store.get_task_record("task-new") assert "".join(record.output_text) == "fresh output" + assert publisher_contexts[-1].candidate_step_order == [ + "template_generating", + "reviewing", + "cost_estimating", + ] + assert "reviewing" in publisher_contexts[-1].a2a_artifacts_by_step_id @pytest.mark.asyncio diff --git a/tests/a2a/test_pipeline_snapshot.py b/tests/a2a/test_pipeline_snapshot.py index e2a47161..a0a207a6 100644 --- a/tests/a2a/test_pipeline_snapshot.py +++ b/tests/a2a/test_pipeline_snapshot.py @@ -1042,6 +1042,149 @@ def test_reduce_artifact_created_keeps_opaque_artifact_uri() -> None: assert snapshot["display"]["artifacts"][0]["uri"] == "iac-code-artifact://artifact-1/template.yaml" +def test_reduce_final_artifact_supersedes_intermediate_for_same_candidate_path() -> None: + parent = {"runId": "step-evaluate-1", "id": "evaluate", "index": 1, "total": 1, "attempt": 1} + candidate_a = {"runId": "candidate-eval-0-1", "id": "eval", "index": 0, "attempt": 1} + candidate_b = {"runId": "candidate-eval-1-1", "id": "eval", "index": 1, "attempt": 1} + generated_step_a = { + "runId": "candidate-eval-0-1-template_generating-1", + "id": "template_generating", + "index": 1, + "total": 2, + "attempt": 1, + } + review_step_a = { + "runId": "candidate-eval-0-1-reviewing-1", + "id": "reviewing", + "index": 2, + "total": 2, + "attempt": 1, + } + generated_step_b = { + "runId": "candidate-eval-1-1-template_generating-1", + "id": "template_generating", + "index": 1, + "total": 2, + "attempt": 1, + } + generated_a = _base("evt-generated-a", 1, "artifact_created", scope="candidate_step") + generated_a["step"] = parent + generated_a["candidate"] = candidate_a + generated_a["candidateStep"] = generated_step_a + generated_a["artifact"] = { + "artifactId": "generated-a", + "filename": "main.yaml", + "role": "intermediate", + "supersedesPath": "templates/main.yaml", + "content": "generated A", + } + generated_b = _base("evt-generated-b", 2, "artifact_created", scope="candidate_step") + generated_b["step"] = parent + generated_b["candidate"] = candidate_b + generated_b["candidateStep"] = generated_step_b + generated_b["artifact"] = { + "artifactId": "generated-b", + "filename": "main.yaml", + "role": "intermediate", + "supersedesPath": "templates/main.yaml", + "content": "generated B", + } + reviewed_a = _base("evt-reviewed-a", 3, "artifact_created", scope="candidate_step") + reviewed_a["step"] = parent + reviewed_a["candidate"] = candidate_a + reviewed_a["candidateStep"] = review_step_a + reviewed_a["artifact"] = { + "artifactId": "reviewed-a", + "filename": "main.yaml", + "role": "final", + "supersedesPath": "templates/main.yaml", + "content": "reviewed A", + } + + snapshot = reduce_pipeline_events([generated_a, generated_b, reviewed_a]) + + assert len(snapshot["display"]["artifacts"]) == 2 + artifacts = {artifact["candidate"]["runId"]: artifact for artifact in snapshot["display"]["artifacts"]} + assert set(artifacts) == {"candidate-eval-0-1", "candidate-eval-1-1"} + assert artifacts["candidate-eval-0-1"]["artifactId"] == "reviewed-a" + assert artifacts["candidate-eval-0-1"]["role"] == "final" + assert artifacts["candidate-eval-0-1"]["sequence"] == 3 + assert artifacts["candidate-eval-0-1"]["candidateStep"]["id"] == "reviewing" + assert artifacts["candidate-eval-1-1"]["artifactId"] == "generated-b" + assert artifacts["candidate-eval-1-1"]["role"] == "intermediate" + + +def test_reduce_artifact_replacement_prefers_stable_supersedes_key_over_public_path() -> None: + candidate = {"runId": "candidate-eval-0-1", "id": "eval", "index": 0, "attempt": 1} + key_a = "sha256:1111111111111111" + key_b = "sha256:2222222222222222" + generated_a = _base("evt-generated-a", 1, "artifact_created", scope="candidate") + generated_a["candidate"] = candidate + generated_a["artifact"] = { + "artifactId": "generated-a", + "filename": "a.yaml", + "role": "intermediate", + "supersedesPath": "[PATH]", + "supersedesKey": key_a, + } + generated_b = _base("evt-generated-b", 2, "artifact_created", scope="candidate") + generated_b["candidate"] = candidate + generated_b["artifact"] = { + "artifactId": "generated-b", + "filename": "b.yaml", + "role": "intermediate", + "supersedesPath": "[PATH]", + "supersedesKey": key_b, + } + reviewed_a = _base("evt-reviewed-a", 3, "artifact_created", scope="candidate") + reviewed_a["candidate"] = candidate + reviewed_a["artifact"] = { + "artifactId": "reviewed-a", + "filename": "a.yaml", + "role": "final", + "supersedesPath": "[PATH]", + "supersedesKey": key_a, + } + + snapshot = reduce_pipeline_events([generated_a, generated_b, reviewed_a]) + + artifacts = {artifact["supersedesKey"]: artifact for artifact in snapshot["display"]["artifacts"]} + assert set(artifacts) == {key_a, key_b} + assert artifacts[key_a]["artifactId"] == "reviewed-a" + assert artifacts[key_a]["role"] == "final" + assert artifacts[key_b]["artifactId"] == "generated-b" + assert artifacts[key_b]["role"] == "intermediate" + + +def test_reduce_generated_template_remains_final_when_review_is_disabled() -> None: + artifact = _base("evt-generated", 1, "artifact_created", scope="candidate") + artifact["candidate"] = {"runId": "candidate-eval-0-1", "id": "eval", "index": 0, "attempt": 1} + artifact["artifact"] = { + "artifactId": "generated", + "filename": "main.yaml", + "role": "final", + "supersedesPath": "templates/main.yaml", + } + + snapshot = reduce_pipeline_events([artifact]) + + assert snapshot["display"]["artifacts"] == [ + { + "artifactId": "generated", + "filename": "main.yaml", + "role": "final", + "supersedesPath": "templates/main.yaml", + "id": "generated", + "scope": "candidate", + "runId": "candidate-eval-0-1", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventId": "evt-generated", + "candidate": {"runId": "candidate-eval-0-1", "id": "eval", "index": 0, "attempt": 1}, + } + ] + + def test_reduce_deduplicates_existing_display_items_and_rollbacks() -> None: existing = reduce_pipeline_events([]) existing["display"]["diagrams"] = [ diff --git a/tests/a2a/test_pipeline_stream.py b/tests/a2a/test_pipeline_stream.py index 291e3634..4e54d898 100644 --- a/tests/a2a/test_pipeline_stream.py +++ b/tests/a2a/test_pipeline_stream.py @@ -904,6 +904,100 @@ async def test_publish_candidate_step_completed_externalizes_configured_conclusi assert snapshot["display"]["artifacts"][0]["mediaType"] == "text/yaml" +@pytest.mark.asyncio +async def test_publish_externalized_conclusion_artifact_preserves_final_metadata(tmp_path: Path) -> None: + store = A2AArtifactStore(tmp_path / "artifacts") + raw_superseded_path = str(tmp_path / "generated" / "template.yaml") + supersedes_key = fingerprint_text(raw_superseded_path) + publisher, queue = _publisher( + tmp_path, + artifact_store=store, + exposure_types=[A2AExposureType.TOOL_TRACE], + a2a_artifacts_by_step_id={ + "template_generating": [ + { + "path": "conclusion.file_path", + "content": "conclusion.template", + "media_type": "auto", + "role": "final", + "supersedes_path": "conclusion.original_file_path", + } + ] + }, + ) + + await publisher.publish( + PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_STARTED, + step_id=None, + timestamp=1717821600.0, + data={ + "sub_pipeline_id": "eval-abcd", + "candidate_index": 0, + "candidate_name": "candidate", + "parent_step_id": "evaluate_candidates", + "total_steps": 1, + }, + ) + ) + await publisher.publish( + PipelineEvent( + type=PipelineEventType.SUB_STEP_COMPLETED, + step_id="template_generating", + timestamp=1717821601.0, + data={ + "sub_pipeline_id": "eval-abcd", + "candidate_index": 0, + "step_id": "template_generating", + "step_index": 0, + "total_steps": 1, + "conclusion": { + "file_path": "templates/reviewed-template.yaml", + "original_file_path": raw_superseded_path, + "template": "ROSTemplateFormatVersion: '2015-09-01'\nResources: {}\n", + }, + }, + ) + ) + + status_events = [ + dump(event)["metadata"]["iac_code"]["pipeline"] + for event in queue.events + if dump(event).get("metadata", {}).get("iac_code", {}).get("pipeline", {}).get("eventType") + ] + artifact_status = status_events[-1] + assert artifact_status["eventType"] == "artifact_created" + assert artifact_status["artifact"]["role"] == "final" + assert artifact_status["artifact"]["supersedesPath"] == "[PATH]" + assert artifact_status["artifact"]["supersedesKey"] == supersedes_key + assert artifact_status["data"]["role"] == "final" + assert artifact_status["data"]["supersedesPath"] == "[PATH]" + assert artifact_status["data"]["supersedesKey"] == supersedes_key + + journal_event = publisher.journal.read_all()[-1] + assert journal_event["artifact"]["role"] == "final" + assert journal_event["artifact"]["supersedesPath"] == "[PATH]" + assert journal_event["artifact"]["supersedesKey"] == supersedes_key + assert journal_event["data"]["role"] == "final" + assert journal_event["data"]["supersedesPath"] == "[PATH]" + assert journal_event["data"]["supersedesKey"] == supersedes_key + + snapshot = publisher.snapshot_store.load() + assert snapshot is not None + artifact = snapshot["display"]["artifacts"][0] + assert artifact["role"] == "final" + assert artifact["supersedesPath"] == "[PATH]" + assert artifact["supersedesKey"] == supersedes_key + artifact_metadata = ( + artifact_status["artifact"], + artifact_status["data"], + journal_event["artifact"], + journal_event["data"], + artifact, + ) + assert raw_superseded_path not in str(artifact_metadata) + + @pytest.mark.asyncio async def test_publish_artifact_created_omits_tool_metadata_when_tool_trace_disabled(tmp_path: Path) -> None: store = A2AArtifactStore(tmp_path / "artifacts") diff --git a/tests/agent/test_agent_loop_new.py b/tests/agent/test_agent_loop_new.py index caed5e94..7ff0596a 100644 --- a/tests/agent/test_agent_loop_new.py +++ b/tests/agent/test_agent_loop_new.py @@ -9,6 +9,7 @@ from iac_code.agent.agent_loop import AgentLoop from iac_code.tools.base import ToolResult from iac_code.tools.tool_executor import ToolExecutor +from iac_code.types.permissions import PermissionResult from iac_code.types.stream_events import ( CompactionEvent, MessageEndEvent, @@ -1753,6 +1754,100 @@ async def fake_stream(messages, system, tools=None, max_tokens=8192): assert modifier_called assert loop._allowed_tool_rules == ["read:*"] + async def test_externalized_tool_result_event_carries_internal_result_path( + self, mock_provider, mock_registry, tmp_path + ): + call_count = 0 + + async def fake_stream(messages, system, tools=None, max_tokens=8192): + nonlocal call_count + call_count += 1 + if call_count == 1: + yield MessageStartEvent(message_id="m1") + yield ToolUseStartEvent(tool_use_id="toolu_1", name="read_file") + yield ToolUseEndEvent(tool_use_id="toolu_1", name="read_file", input={"path": "a.txt"}) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + return + yield MessageStartEvent(message_id="m2") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + stored_result = tmp_path / "toolu_1.txt" + mock_provider.stream = fake_stream + mock_registry.list_tools.return_value = [SimpleNamespace(name="read_file", description="Read", input_schema={})] + + loop = AgentLoop(provider_manager=mock_provider, system_prompt="test", tool_registry=mock_registry) + loop._result_storage = MagicMock() + loop._result_storage.process.return_value = SimpleNamespace( + content="preview result", + is_externalized=True, + file_path=str(stored_result), + ) + loop.context_manager = MagicMock() + loop.context_manager.get_api_messages.return_value = [] + loop.context_manager.needs_compaction.return_value = False + loop._tool_executor.execute_batch = AsyncMock( + return_value=[ToolResult(content="raw result", metadata={"existing": "metadata"})] + ) + + events = [e async for e in loop.run_streaming("Hi")] + + tool_results = [e for e in events if isinstance(e, ToolResultEvent)] + assert len(tool_results) == 1 + assert tool_results[0].result == "preview result" + assert tool_results[0].metadata == { + "existing": "metadata", + "_iac_code_externalized_result_path": str(stored_result), + } + tool_blocks = loop.context_manager.add_tool_results.call_args.args[0] + assert tool_blocks[0].content == "preview result" + assert tool_blocks[0].metadata == {"_iac_code_externalized_result_path": str(stored_result)} + + async def test_tool_use_start_event_carries_renderer_tool(self, mock_provider, mock_registry): + call_count = 0 + + async def fake_stream(messages, system, tools=None, max_tokens=8192): + nonlocal call_count + call_count += 1 + if call_count > 1: + yield MessageStartEvent(message_id="msg2") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + return + yield MessageStartEvent(message_id="msg1") + yield ToolUseStartEvent(tool_use_id="tool1", name="rendered_tool") + yield ToolUseEndEvent(tool_use_id="tool1", name="rendered_tool", input={}) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + + tool = SimpleNamespace( + name="rendered_tool", + description="Rendered tool", + input_schema={}, + needs_event_queue=lambda: False, + check_permissions=AsyncMock(return_value=PermissionResult(behavior="allow")), + render_tool_result_message=lambda output, *, is_error=False, verbose=False: ( + "verbose result" if verbose else "compact result" + ), + ) + mock_provider.stream = fake_stream + mock_registry.list_tools.return_value = [tool] + mock_registry.get.side_effect = lambda name: tool if name == "rendered_tool" else None + + loop = AgentLoop(provider_manager=mock_provider, system_prompt="test", tool_registry=mock_registry) + loop._result_storage = MagicMock() + loop._result_storage.process.return_value = SimpleNamespace(content="processed result") + loop.context_manager = MagicMock() + loop.context_manager.get_api_messages.return_value = [] + loop.context_manager.needs_compaction.return_value = False + loop._tool_executor.execute_batch = AsyncMock(return_value=[ToolResult(content="raw result")]) + + events = [e async for e in loop.run_streaming("Hi")] + + tool_results = [e for e in events if isinstance(e, ToolResultEvent)] + assert len(tool_results) == 1 + assert tool_results[0].metadata is None + tool_starts = [e for e in events if isinstance(e, ToolUseStartEvent)] + assert len(tool_starts) == 1 + assert tool_starts[0].renderer_tool is tool + async def test_terminal_error_step_result_metadata_stops_streaming(self, mock_provider, mock_registry): from iac_code.pipeline.engine.types import StepResult, StepStatus diff --git a/tests/agent/test_message.py b/tests/agent/test_message.py index 566fb62f..3033cbb0 100644 --- a/tests/agent/test_message.py +++ b/tests/agent/test_message.py @@ -72,6 +72,21 @@ def test_tool_result_block_with_error(self): ) assert block.is_error is True + def test_tool_result_block_persists_internal_metadata_but_omits_from_api(self): + """Test ToolResultBlock metadata can be stored without reaching provider APIs.""" + block = ToolResultBlock( + tool_use_id="toolu_789", + content="preview", + metadata={"_iac_code_externalized_result_path": "/tmp/full-result.json"}, + ) + msg = Message(role="user", content=[block]) + + assert block.metadata == {"_iac_code_externalized_result_path": "/tmp/full-result.json"} + assert msg.to_dict()["content"][0]["metadata"] == { + "_iac_code_externalized_result_path": "/tmp/full-result.json" + } + assert "metadata" not in msg.to_api_format()["content"][0] + class TestMessage: """Tests for Message.""" diff --git a/tests/cli/test_output_formats.py b/tests/cli/test_output_formats.py index 330df22f..426fe655 100644 --- a/tests/cli/test_output_formats.py +++ b/tests/cli/test_output_formats.py @@ -5,6 +5,7 @@ import asyncio import io import json +from types import SimpleNamespace from iac_code.cli.output_formats import ( JsonWriter, @@ -14,6 +15,7 @@ create_writer, ) from iac_code.services.permissions.audit import fingerprint_text +from iac_code.tools.result_storage import EXTERNALIZED_RESULT_PATH_METADATA_KEY, ResultStorage from iac_code.types.permissions import PermissionAuditMetadata, PermissionAuditSettings, PermissionResult from iac_code.types.stream_events import ( ErrorEvent, @@ -253,6 +255,24 @@ def test_tool_events_emitted(self) -> None: assert second["type"] == "tool_result" assert "signature-secret" not in stream.getvalue() + def test_tool_use_start_omits_renderer_tool(self) -> None: + stream = io.StringIO() + writer = StreamJsonWriter(stream) + writer.handle( + ToolUseStartEvent( + tool_use_id="tu_1", + name="infraguard_scan", + renderer_tool=SimpleNamespace(name="infraguard_scan"), + ) + ) + + data = json.loads(stream.getvalue()) + assert data == { + "tool_use_id": "tu_1", + "name": "infraguard_scan", + "type": "tool_use_start", + } + def test_tool_result_omits_null_metadata_for_field_stability(self) -> None: stream = io.StringIO() writer = StreamJsonWriter(stream) @@ -282,6 +302,59 @@ def test_tool_result_relativizes_public_paths_without_emitting_roots(self) -> No assert "publicPathRoots" not in rendered assert "/Users/alice" not in rendered + def test_tool_result_redacts_embedded_file_content_json_string(self) -> None: + stream = io.StringIO() + writer = StreamJsonWriter(stream) + writer.handle( + ToolResultEvent( + tool_use_id="tu_1", + tool_name="infraguard_scan", + result=json.dumps( + { + "file_path": "templates/demo.yml", + "file_sha256": "sha256-value", + "file_content": "ROSTemplateFormatVersion: '2015-09-01'\nResources: {}\n", + }, + ensure_ascii=False, + ), + ) + ) + + data = json.loads(stream.getvalue()) + rendered = json.dumps(data, ensure_ascii=False) + assert "ROSTemplateFormatVersion" not in rendered + assert "file_content" in rendered + assert "sha256-value" in rendered + + def test_tool_result_redacts_externalized_file_content_preview(self, tmp_path) -> None: + stream = io.StringIO() + writer = StreamJsonWriter(stream) + raw_result = json.dumps( + { + "file_path": "templates/demo.yml", + "file_sha256": "sha256-value", + "file_content": "ROSTemplateFormatVersion: '2015-09-01'\nResources: {}\n" + ("X" * 500), + }, + ensure_ascii=False, + ) + preview = ( + ResultStorage( + storage_dir=str(tmp_path / "tool-results"), + max_inline_chars=10, + preview_chars=180, + ) + .process("tu_1", raw_result) + .content + ) + assert "ROSTemplateFormatVersion" in preview + writer.handle(ToolResultEvent(tool_use_id="tu_1", tool_name="infraguard_scan", result=preview)) + + data = json.loads(stream.getvalue()) + rendered = json.dumps(data, ensure_ascii=False) + assert "ROSTemplateFormatVersion" not in rendered + assert "file_content" in rendered + assert "sha256-value" in rendered + def test_subagent_tool_event_omits_raw_child_input(self) -> None: stream = io.StringIO() writer = StreamJsonWriter(stream) @@ -394,6 +467,27 @@ def test_tool_result_preserves_non_null_metadata(self) -> None: assert data["type"] == "tool_result" assert data["metadata"] == {"step_result": {"step_id": "x"}} + def test_tool_result_omits_internal_externalized_result_path_metadata(self) -> None: + stream = io.StringIO() + writer = StreamJsonWriter(stream) + writer.handle( + ToolResultEvent( + tool_use_id="tu_1", + tool_name="infraguard_scan", + result="preview", + metadata={ + EXTERNALIZED_RESULT_PATH_METADATA_KEY: "/Users/alice/.iac-code/tool-results/raw.txt", + "step_result": {"step_id": "x"}, + }, + ) + ) + + data = json.loads(stream.getvalue()) + rendered = json.dumps(data, ensure_ascii=False) + assert EXTERNALIZED_RESULT_PATH_METADATA_KEY not in rendered + assert "/Users/alice" not in rendered + assert data["metadata"] == {"step_result": {"step_id": "x"}} + def test_failed_tool_result_redacts_encoded_malformed_artifact_uri(self) -> None: stream = io.StringIO() writer = StreamJsonWriter(stream) diff --git a/tests/pipeline/engine/test_complete_step_tool.py b/tests/pipeline/engine/test_complete_step_tool.py index d31514cf..02f70090 100644 --- a/tests/pipeline/engine/test_complete_step_tool.py +++ b/tests/pipeline/engine/test_complete_step_tool.py @@ -1,3 +1,6 @@ +import hashlib +from pathlib import Path + import pytest from iac_code.pipeline.engine.complete_step_tool import CompleteStepTool @@ -29,6 +32,26 @@ def test_has_input_schema(self, tool): assert "conclusion" in schema["properties"] assert "conclusion" in schema["required"] + @pytest.mark.asyncio + async def test_completion_guard_message_key_renders_translated_message(self, step_config): + tool = CompleteStepTool( + step_config, + completion_guards=[ + { + "require_tool": "ask_user_question", + "when_conclusion_field_equals": {"category": "chat"}, + "message_key": "intent_not_deployment_request", + } + ], + completion_guard_state={"successful_tools": set()}, + ) + + result = await tool.execute(tool_input={"conclusion": {"category": "chat"}}, context=ToolContext()) + + assert result.is_error + assert "deployment or cloud resource request" in result.content + assert "intent_not_deployment_request" not in result.content + class TestDynamicInputSchema: def test_schema_with_conclusion_schema(self): @@ -217,6 +240,10 @@ async def test_rejects_when_rollback_target_count_exceeds_limit(self): class TestCompletionGuards: + @staticmethod + def _sha256(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + @staticmethod def _deploying_success_guard() -> dict: return { @@ -259,6 +286,128 @@ def _deploying_tool(result_records: list[dict] | None = None) -> CompleteStepToo }, ) + @staticmethod + def _review_guards() -> list[dict]: + return [ + { + "when_conclusion_field_equals": {"validated": True}, + "require_tool_result": { + "tool": "ros_validate_template", + "match_conclusion_field": "file_path", + "match_result_field": "input.template_url", + }, + }, + { + "when_conclusion_field_equals": {"review_passed": True}, + "require_tool_result": { + "tool": "infraguard_scan", + "latest_match": True, + "after_tool_result": { + "tool": "ros_validate_template", + "match_conclusion_field": "file_path", + "match_result_field": "input.template_url", + }, + "match_conclusion_field": "file_path", + "match_result_field": "file_path", + "result_field_equals": {"passed": True, "blocking_findings": 0}, + }, + }, + ] + + @staticmethod + def _review_tool(result_records: list[dict] | None = None) -> CompleteStepTool: + config = StepConfig( + step_id="reviewing", + conclusion_field="review", + forward=None, + conclusion_schema={ + "type": "object", + "required": ["file_path", "validated", "review_passed"], + "additionalProperties": False, + "properties": { + "file_path": {"type": "string"}, + "validated": {"type": "boolean"}, + "review_passed": {"type": "boolean"}, + }, + }, + ) + return CompleteStepTool( + config, + completion_guards=TestCompletionGuards._review_guards(), + completion_guard_state={ + "successful_tools": set(), + "tool_results": {}, + "tool_result_records": list(result_records or []), + }, + ) + + @staticmethod + def _selling_review_tool(result_records: list[dict] | None = None) -> CompleteStepTool: + from iac_code.pipeline.engine.loader import load_pipeline_dir + + selling_dir = Path(__file__).resolve().parents[3] / "src" / "iac_code" / "pipeline" / "selling" + loaded = load_pipeline_dir(selling_dir, feature_flag_overrides={"enable_reviewing": True}) + review_step = next( + step for step in loaded.sub_pipelines["evaluate_candidate"].steps if step.step_id == "reviewing" + ) + config = StepConfig( + step_id=review_step.step_id, + conclusion_field=review_step.conclusion_field, + forward=review_step.forward, + conclusion_schema=review_step.conclusion_schema, + ) + return CompleteStepTool( + config, + completion_guards=review_step.completion_guards, + completion_guard_state={ + "successful_tools": set(), + "tool_results": {}, + "tool_result_records": list(result_records or []), + }, + ) + + @staticmethod + def _validate_template_record( + file_path: str = "oss://bucket/template.yaml", + *, + result: dict | None = None, + ) -> dict: + return { + "tool_name": "ros_validate_template", + "input": {"template_url": file_path}, + "result": {"Description": "Valid"} if result is None else result, + "is_error": False, + } + + @staticmethod + def _infraguard_scan_record( + file_path: str = "oss://bucket/template.yaml", + *, + passed: bool = True, + blocking_findings: int = 0, + file_sha256: str | None = None, + file_content: str | None = None, + ) -> dict: + result = { + "file_path": file_path, + "passed": passed, + "blocking_findings": blocking_findings, + "findings": [], + "summary": {}, + "selected_aspects": ["security"], + "expanded_policies": ["pack:aliyun:security"], + } + if file_sha256 is not None: + result["file_sha256"] = file_sha256 + if file_content is not None: + result["file_content"] = file_content + return { + "tool_name": "infraguard_scan", + "input": {"file_path": file_path}, + "result": result, + "is_error": False, + } + @pytest.mark.asyncio async def test_required_tool_result_rejects_deploying_success_without_create_stack_result(self): tool = self._deploying_tool() @@ -449,6 +598,596 @@ async def test_required_conclusion_any_of_rejects_missing_clarification_result(s assert "clarification_choice" in result.content assert "clarification_text" in result.content + @pytest.mark.asyncio + async def test_review_guard_rejects_validated_conclusion_without_validate_template(self): + tool = self._review_tool([self._infraguard_scan_record()]) + + result = await tool.execute( + tool_input={ + "conclusion": { + "file_path": "oss://bucket/template.yaml", + "validated": True, + "review_passed": False, + } + }, + context=ToolContext(), + ) + + assert result.is_error + assert "ros_validate_template" in result.content + + @pytest.mark.asyncio + async def test_review_guard_rejects_review_passed_without_final_scan(self): + tool = self._review_tool([self._validate_template_record()]) + + result = await tool.execute( + tool_input={ + "conclusion": { + "file_path": "oss://bucket/template.yaml", + "validated": True, + "review_passed": True, + } + }, + context=ToolContext(), + ) + + assert result.is_error + assert "infraguard_scan" in result.content + + @pytest.mark.asyncio + async def test_review_guard_rejects_failed_scan_with_field_mismatch_message(self): + tool = self._review_tool( + [ + self._validate_template_record(), + self._infraguard_scan_record(passed=False, blocking_findings=1), + ] + ) + + result = await tool.execute( + tool_input={ + "conclusion": { + "file_path": "oss://bucket/template.yaml", + "validated": True, + "review_passed": True, + } + }, + context=ToolContext(), + ) + + assert result.is_error + assert "passed" in result.content + assert "True" in result.content + assert "False" in result.content + assert "Call infraguard_scan first" not in result.content + + @pytest.mark.asyncio + async def test_review_guard_rejects_when_latest_scan_for_same_file_fails(self): + tool = self._review_tool( + [ + self._validate_template_record(), + self._infraguard_scan_record(passed=True, blocking_findings=0), + self._infraguard_scan_record(passed=False, blocking_findings=1), + ] + ) + + result = await tool.execute( + tool_input={ + "conclusion": { + "file_path": "oss://bucket/template.yaml", + "validated": True, + "review_passed": True, + } + }, + context=ToolContext(), + ) + + assert result.is_error + assert "passed" in result.content + assert "False" in result.content + + @pytest.mark.asyncio + async def test_review_guard_rejects_scan_that_precedes_validate_template(self): + tool = self._review_tool( + [ + self._infraguard_scan_record(passed=True, blocking_findings=0), + self._validate_template_record(), + ] + ) + + result = await tool.execute( + tool_input={ + "conclusion": { + "file_path": "oss://bucket/template.yaml", + "validated": True, + "review_passed": True, + } + }, + context=ToolContext(), + ) + + assert result.is_error + assert "infraguard_scan" in result.content + + @pytest.mark.asyncio + async def test_review_guard_rejects_wrong_scan_file_path(self): + tool = self._review_tool( + [ + self._validate_template_record(), + self._infraguard_scan_record("oss://bucket/other.yaml"), + ] + ) + + result = await tool.execute( + tool_input={ + "conclusion": { + "file_path": "oss://bucket/template.yaml", + "validated": True, + "review_passed": True, + } + }, + context=ToolContext(), + ) + + assert result.is_error + assert "file_path" in result.content + + @pytest.mark.asyncio + async def test_review_guard_accepts_ros_validate_template_for_same_file(self): + tool = self._review_tool( + [ + self._validate_template_record(), + self._infraguard_scan_record(), + ] + ) + + result = await tool.execute( + tool_input={ + "conclusion": { + "file_path": "oss://bucket/template.yaml", + "validated": True, + "review_passed": True, + } + }, + context=ToolContext(), + ) + + assert not result.is_error + + @pytest.mark.asyncio + async def test_review_guard_accepts_validate_template_and_final_scan(self): + tool = self._review_tool( + [ + self._validate_template_record(), + self._infraguard_scan_record(), + ] + ) + + result = await tool.execute( + tool_input={ + "conclusion": { + "file_path": "oss://bucket/template.yaml", + "validated": True, + "review_passed": True, + } + }, + context=ToolContext(), + ) + + assert not result.is_error + + @pytest.mark.asyncio + async def test_selling_review_guard_accepts_validate_template_response_without_is_success(self): + template = "ROSTemplateFormatVersion: '2015-09-01'\nResources: {}\n" + template_sha256 = self._sha256(template) + tool = self._selling_review_tool( + [ + self._validate_template_record(result={"Description": "Valid"}), + self._infraguard_scan_record(file_sha256=template_sha256, file_content=template), + ] + ) + + result = await tool.execute( + tool_input={ + "conclusion": { + "template": template, + "template_sha256": template_sha256, + "file_path": "oss://bucket/template.yaml", + "region": "cn-hangzhou", + "description": "valid template", + "validated": True, + "review_passed": True, + "review_issues": [], + "selected_review_aspects": [{"key": "security", "reason": "baseline"}], + "skipped_review_aspects": [], + "resolved_infraguard_policies": ["pack:aliyun:security"], + "infraguard_summary": {}, + "fix_summary": "validated", + } + }, + context=ToolContext(), + ) + + assert not result.is_error + + @pytest.mark.asyncio + async def test_selling_review_guard_accepts_clean_initial_scan_without_validate_or_rescan(self): + template = "ROSTemplateFormatVersion: '2015-09-01'\nResources: {}\n" + template_sha256 = self._sha256(template) + tool = self._selling_review_tool( + [ + self._infraguard_scan_record( + "/tmp/template.yaml", + passed=True, + blocking_findings=0, + file_sha256=template_sha256, + file_content=template, + ), + ] + ) + + result = await tool.execute( + tool_input={ + "conclusion": { + "template": template, + "template_sha256": template_sha256, + "file_path": "/tmp/template.yaml", + "region": "cn-hangzhou", + "description": "clean template", + "validated": True, + "review_passed": True, + "review_issues": [], + "selected_review_aspects": [{"key": "security", "reason": "baseline"}], + "skipped_review_aspects": [], + "resolved_infraguard_policies": ["pack:aliyun:security"], + "infraguard_summary": {"passed": True, "blocking_findings": 0}, + "fix_summary": "initial InfraGuard scan passed; no template edits were required", + } + }, + context=ToolContext(), + ) + + assert not result.is_error + + @pytest.mark.asyncio + async def test_selling_review_guard_still_requires_validate_after_same_file_edit(self): + template = "ROSTemplateFormatVersion: '2015-09-01'\nResources: {}\n" + template_sha256 = self._sha256(template) + tool = self._selling_review_tool( + [ + { + "tool_name": "edit_file", + "input": {"path": "/tmp/template.yaml"}, + "result": {"file_path": "/tmp/template.yaml"}, + "is_error": False, + }, + self._infraguard_scan_record( + "/tmp/template.yaml", + passed=True, + blocking_findings=0, + file_sha256=template_sha256, + file_content=template, + ), + ] + ) + + result = await tool.execute( + tool_input={ + "conclusion": { + "template": template, + "template_sha256": template_sha256, + "file_path": "/tmp/template.yaml", + "region": "cn-hangzhou", + "description": "reviewed", + "validated": True, + "review_passed": True, + "review_issues": [], + "selected_review_aspects": [], + "skipped_review_aspects": [], + "resolved_infraguard_policies": [], + "infraguard_summary": {}, + "fix_summary": "done", + } + }, + context=ToolContext(), + ) + + assert result.is_error + assert "ros_validate_template" in result.content + + @pytest.mark.asyncio + async def test_selling_review_guard_rejects_same_file_edit_after_final_scan(self): + template = "ROSTemplateFormatVersion: '2015-09-01'\n" + template_sha256 = self._sha256(template) + tool = self._selling_review_tool( + [ + self._validate_template_record("/tmp/template.yaml"), + self._infraguard_scan_record( + "/tmp/template.yaml", + passed=True, + blocking_findings=0, + file_sha256=template_sha256, + file_content=template, + ), + { + "tool_name": "edit_file", + "input": {"path": "/tmp/template.yaml"}, + "result": {"file_path": "/tmp/template.yaml"}, + "is_error": False, + }, + ] + ) + + result = await tool.execute( + tool_input={ + "conclusion": { + "template": template, + "template_sha256": template_sha256, + "file_path": "/tmp/template.yaml", + "region": "cn-hangzhou", + "description": "reviewed", + "validated": True, + "review_passed": True, + "review_issues": [], + "selected_review_aspects": [], + "skipped_review_aspects": [], + "resolved_infraguard_policies": [], + "infraguard_summary": {}, + "fix_summary": "done", + } + }, + context=ToolContext(), + ) + + assert result.is_error + assert "edit_file" in result.content + assert "infraguard_scan" in result.content + + @pytest.mark.asyncio + async def test_conclusion_sha256_guard_rejects_template_hash_mismatch(self): + config = StepConfig( + step_id="reviewing", + conclusion_field="template", + forward=None, + conclusion_schema={ + "type": "object", + "required": ["template", "template_sha256", "review_passed"], + "additionalProperties": False, + "properties": { + "template": {"type": "string"}, + "template_sha256": {"type": "string"}, + "review_passed": {"type": "boolean"}, + }, + }, + ) + tool = CompleteStepTool( + config, + completion_guards=[ + { + "when_conclusion_field_equals": {"review_passed": True}, + "require_conclusion_sha256": { + "content_field": "template", + "sha256_field": "template_sha256", + }, + } + ], + ) + + result = await tool.execute( + tool_input={ + "conclusion": { + "template": "actual template\n", + "template_sha256": self._sha256("stale template\n"), + "review_passed": True, + } + }, + context=ToolContext(), + ) + + assert result.is_error + assert "template_sha256" in result.content + + @pytest.mark.asyncio + async def test_required_tool_result_rejects_template_hash_mismatch_with_scan(self): + template = "ROSTemplateFormatVersion: '2015-09-01'\n" + config = StepConfig( + step_id="reviewing", + conclusion_field="template", + forward=None, + conclusion_schema={ + "type": "object", + "required": ["file_path", "template_sha256", "review_passed"], + "additionalProperties": False, + "properties": { + "file_path": {"type": "string"}, + "template_sha256": {"type": "string"}, + "review_passed": {"type": "boolean"}, + }, + }, + ) + tool = CompleteStepTool( + config, + completion_guards=[ + { + "when_conclusion_field_equals": {"review_passed": True}, + "require_tool_result": { + "tool": "infraguard_scan", + "match_fields": [ + {"conclusion_field": "file_path", "result_field": "file_path"}, + {"conclusion_field": "template_sha256", "result_field": "file_sha256"}, + ], + "result_field_equals": {"passed": True, "blocking_findings": 0}, + }, + } + ], + completion_guard_state={ + "successful_tools": set(), + "tool_results": {}, + "tool_result_records": [ + { + "tool_name": "infraguard_scan", + "input": {"file_path": "template.yaml"}, + "result": { + "file_path": "template.yaml", + "file_sha256": self._sha256(template), + "passed": True, + "blocking_findings": 0, + }, + "is_error": False, + } + ], + }, + ) + + result = await tool.execute( + tool_input={ + "conclusion": { + "file_path": "template.yaml", + "template_sha256": self._sha256("different template\n"), + "review_passed": True, + } + }, + context=ToolContext(), + ) + + assert result.is_error + assert "template_sha256" in result.content + + @pytest.mark.asyncio + async def test_required_tool_result_rejects_missing_required_result_fields(self): + template = "ROSTemplateFormatVersion: '2015-09-01'\n" + template_sha256 = self._sha256(template) + config = StepConfig( + step_id="reviewing", + conclusion_field="template", + forward=None, + conclusion_schema={ + "type": "object", + "required": ["file_path", "template_sha256", "review_passed"], + "additionalProperties": False, + "properties": { + "file_path": {"type": "string"}, + "template_sha256": {"type": "string"}, + "review_passed": {"type": "boolean"}, + }, + }, + ) + tool = CompleteStepTool( + config, + completion_guards=[ + { + "when_conclusion_field_equals": {"review_passed": True}, + "require_tool_result": { + "tool": "infraguard_scan", + "match_fields": [ + {"conclusion_field": "file_path", "result_field": "file_path"}, + {"conclusion_field": "template_sha256", "result_field": "file_sha256"}, + ], + "result_field_equals": {"passed": True, "blocking_findings": 0}, + "required_result_fields": ["selected_aspects", "expanded_policies"], + }, + } + ], + completion_guard_state={ + "successful_tools": set(), + "tool_results": {}, + "tool_result_records": [ + { + "tool_name": "infraguard_scan", + "input": {"file_path": "template.yaml"}, + "result": { + "file_path": "template.yaml", + "file_sha256": template_sha256, + "passed": True, + "blocking_findings": 0, + }, + "is_error": False, + } + ], + }, + ) + + result = await tool.execute( + tool_input={ + "conclusion": { + "file_path": "template.yaml", + "template_sha256": template_sha256, + "review_passed": True, + } + }, + context=ToolContext(), + ) + + assert result.is_error + assert "selected_aspects" in result.content + + @pytest.mark.asyncio + async def test_disallowed_file_mutation_matches_relative_and_absolute_paths(self, tmp_path): + config = StepConfig( + step_id="reviewing", + conclusion_field="template", + forward=None, + conclusion_schema={ + "type": "object", + "required": ["file_path", "review_passed"], + "additionalProperties": False, + "properties": { + "file_path": {"type": "string"}, + "review_passed": {"type": "boolean"}, + }, + }, + ) + absolute_template = tmp_path / "template.yaml" + tool = CompleteStepTool( + config, + completion_guards=[ + { + "when_conclusion_field_equals": {"review_passed": True}, + "require_tool_result": { + "tool": "infraguard_scan", + "latest_match": True, + "match_conclusion_field": "file_path", + "match_result_field": "file_path", + "result_field_equals": {"passed": True, "blocking_findings": 0}, + "disallow_tool_results_after_match": [ + { + "tools": ["write_file"], + "match_conclusion_field": "file_path", + "match_result_field": "result.file_path", + "message": "rerun validation", + } + ], + }, + } + ], + completion_guard_state={ + "cwd": str(tmp_path), + "successful_tools": set(), + "tool_results": {}, + "tool_result_records": [ + { + "tool_name": "infraguard_scan", + "input": {"file_path": "template.yaml"}, + "result": {"file_path": "template.yaml", "passed": True, "blocking_findings": 0}, + "is_error": False, + }, + { + "tool_name": "write_file", + "input": {"path": str(absolute_template)}, + "result": {"file_path": str(absolute_template)}, + "is_error": False, + }, + ], + }, + ) + + result = await tool.execute( + tool_input={"conclusion": {"file_path": "template.yaml", "review_passed": True}}, + context=ToolContext(), + ) + + assert result.is_error + assert "rerun validation" in result.content + class TestSchemaValidation: def test_missing_conclusion_validation_error_includes_current_step_schema(self): diff --git a/tests/pipeline/engine/test_loader.py b/tests/pipeline/engine/test_loader.py index 9d334067..0f63abae 100644 --- a/tests/pipeline/engine/test_loader.py +++ b/tests/pipeline/engine/test_loader.py @@ -181,6 +181,88 @@ def test_feature_flags_env_override(self, tmp_path): loaded = load_pipeline_dir(tmp_path) assert loaded.feature_flags == {"cost_estimation": False} + def test_load_pipeline_dir_applies_feature_flag_overrides(self, tmp_path): + yaml_content = dedent("""\ + name: test + context_dependencies: + template: [] + review: [template] + cost: [template] + feature_flags: + infraguard_review: + default: false + env: IAC_CODE_INFRAGUARD_REVIEW + max_rollbacks: 1 + steps: + - id: template_generating + conclusion_field: template + forward: review + prompt: prompts/template.md + - id: review + conclusion_field: review + forward: cost_estimating + prompt: prompts/review.md + enabled_when: infraguard_review + - id: cost_estimating + conclusion_field: cost + forward: null + prompt: prompts/cost.md + """) + _write_pipeline( + tmp_path, + yaml_content, + {"template.md": "T", "review.md": "R", "cost.md": "C"}, + ) + + with patch.dict("os.environ", {"IAC_CODE_INFRAGUARD_REVIEW": "true"}): + loaded = load_pipeline_dir(tmp_path, feature_flag_overrides={"infraguard_review": False}) + + assert loaded.feature_flags == {"infraguard_review": False} + assert [step.step_id for step in loaded.steps] == ["template_generating", "cost_estimating"] + assert loaded.steps[0].forward == "cost_estimating" + + def test_load_pipeline_dir_retains_prerequisites_and_resolution(self, tmp_path): + yaml_content = dedent("""\ + name: test + context_dependencies: + intent: [] + prerequisites: + cloud_account: + required: true + max_rollbacks: 1 + steps: + - id: parse + conclusion_field: intent + forward: null + prompt: prompts/parse.md + """) + _write_pipeline(tmp_path, yaml_content, {"parse.md": "P"}) + prerequisite_resolution = {"cloud_account": {"status": "satisfied"}} + + loaded = load_pipeline_dir(tmp_path, prerequisite_resolution=prerequisite_resolution) + + assert loaded.prerequisites == {"cloud_account": {"required": True}} + assert loaded.prerequisite_resolution is prerequisite_resolution + + @pytest.mark.parametrize("raw_prerequisites", ["[cloud_account]", "cloud_account"]) + def test_load_pipeline_dir_rejects_invalid_prerequisites(self, tmp_path, raw_prerequisites): + yaml_content = dedent(f"""\ + name: test + context_dependencies: + intent: [] + prerequisites: {raw_prerequisites} + max_rollbacks: 1 + steps: + - id: parse + conclusion_field: intent + forward: null + prompt: prompts/parse.md + """) + _write_pipeline(tmp_path, yaml_content, {"parse.md": "P"}) + + with pytest.raises(ValueError, match="prerequisites"): + load_pipeline_dir(tmp_path) + def test_discovers_skills(self, tmp_path): _write_pipeline(tmp_path, MINIMAL_YAML, {"step_a.md": "A", "step_b.md": "B"}) skills_dir = tmp_path / "skills" / "skill-x" diff --git a/tests/pipeline/engine/test_loader_allow_user_escapes.py b/tests/pipeline/engine/test_loader_allow_user_escapes.py index 0640c17d..d4cc5d4c 100644 --- a/tests/pipeline/engine/test_loader_allow_user_escapes.py +++ b/tests/pipeline/engine/test_loader_allow_user_escapes.py @@ -2,6 +2,7 @@ from pathlib import Path +import pytest import yaml from iac_code.pipeline.engine.loader import load_pipeline_dir @@ -73,11 +74,14 @@ def test_loader_parses_step_a2a_artifacts(tmp_path): "conclusion_field": "x", "forward": None, "prompt": "prompts/s1.md", + "config": {"mode": "strict", "max_findings": 5}, "a2a_artifacts": [ { "path": "conclusion.file_path", "content": "conclusion.template", "media_type": "auto", + "role": "intermediate", + "supersedesPath": "conclusion.original_file_path", } ], } @@ -87,26 +91,148 @@ def test_loader_parses_step_a2a_artifacts(tmp_path): loaded = load_pipeline_dir(tmp_path) + assert loaded.steps[0].config == {"mode": "strict", "max_findings": 5} assert loaded.steps[0].a2a_artifacts == [ A2AArtifactSpec( path="conclusion.file_path", content="conclusion.template", media_type="auto", + role="intermediate", + supersedes_path="conclusion.original_file_path", ) ] -def test_selling_cost_estimating_can_emit_repaired_template_artifact() -> None: +@pytest.mark.parametrize("config", [["strict"], "strict"]) +def test_loader_rejects_invalid_step_config(tmp_path, config): + _write_pipeline( + tmp_path, + { + "steps": [ + { + "id": "s1", + "conclusion_field": "x", + "forward": None, + "prompt": "prompts/s1.md", + "config": config, + } + ] + }, + ) + + with pytest.raises(ValueError, match="Step 's1'.*config"): + load_pipeline_dir(tmp_path) + + +def test_loader_resolves_a2a_artifact_roles_after_filtering(tmp_path): + _write_pipeline( + tmp_path, + { + "context_dependencies": {"template": [], "review": []}, + "feature_flags": {"review_enabled": {"default": True}}, + "steps": [ + { + "id": "template_generating", + "conclusion_field": "template", + "forward": "review", + "prompt": "prompts/s1.md", + "a2a_artifacts": [ + { + "path": "conclusion.file_path", + "content": "conclusion.template", + } + ], + }, + { + "id": "review", + "conclusion_field": "review", + "forward": None, + "prompt": "prompts/s1.md", + "enabled_when": "review_enabled", + "a2a_artifacts": [ + { + "path": "conclusion.reviewed_file_path", + "content": "conclusion.reviewed_template", + "supersedes_path": "conclusion.file_path", + } + ], + }, + ], + }, + ) + + loaded = load_pipeline_dir(tmp_path) + + assert loaded.steps[0].a2a_artifacts[0].role == "intermediate" + assert loaded.steps[1].a2a_artifacts[0].role == "final" + + filtered = load_pipeline_dir(tmp_path, feature_flag_overrides={"review_enabled": False}) + + assert [step.step_id for step in filtered.steps] == ["template_generating"] + assert filtered.steps[0].a2a_artifacts[0].role == "final" + + +def test_loader_resolves_chained_a2a_artifact_replacements(tmp_path): + _write_pipeline( + tmp_path, + { + "context_dependencies": {"a": [], "b": [], "c": []}, + "steps": [ + { + "id": "a", + "conclusion_field": "a", + "forward": "b", + "prompt": "prompts/s1.md", + "a2a_artifacts": [ + { + "path": "conclusion.a", + "content": "conclusion.template", + } + ], + }, + { + "id": "b", + "conclusion_field": "b", + "forward": "c", + "prompt": "prompts/s1.md", + "a2a_artifacts": [ + { + "path": "conclusion.b", + "content": "conclusion.template", + "supersedes_path": "conclusion.a", + } + ], + }, + { + "id": "c", + "conclusion_field": "c", + "forward": None, + "prompt": "prompts/s1.md", + "a2a_artifacts": [ + { + "path": "conclusion.c", + "content": "conclusion.template", + "supersedes_path": "conclusion.b", + } + ], + }, + ], + }, + ) + + loaded = load_pipeline_dir(tmp_path) + + assert [step.a2a_artifacts[0].role for step in loaded.steps] == ["intermediate", "intermediate", "final"] + + +def test_selling_cost_estimating_reads_template_without_reemitting_template_artifact() -> None: selling_dir = Path(__file__).parents[3] / "src" / "iac_code" / "pipeline" / "selling" loaded = load_pipeline_dir(selling_dir) - cost_step = loaded.sub_pipelines["evaluate_candidate"].steps[-1] + template_step, cost_step = loaded.sub_pipelines["evaluate_candidate"].steps + assert template_step.step_id == "template_generating" + assert template_step.a2a_artifacts[0].role == "final" assert cost_step.step_id == "cost_estimating" - assert cost_step.a2a_artifacts == [ - A2AArtifactSpec( - path="conclusion.file_path", - content="conclusion.template", - media_type="auto", - ) - ] + assert cost_step.context_fields == ["template"] + assert cost_step.a2a_artifacts == [] diff --git a/tests/pipeline/engine/test_pipeline_runner_interrupt.py b/tests/pipeline/engine/test_pipeline_runner_interrupt.py index 4af79035..b711dc7d 100644 --- a/tests/pipeline/engine/test_pipeline_runner_interrupt.py +++ b/tests/pipeline/engine/test_pipeline_runner_interrupt.py @@ -236,6 +236,65 @@ def _seed_restored_parallel_judge_state(pipeline_runner): } +def _duplicate_template_pipeline_runner(tmp_path): + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "a.md").write_text("do A", encoding="utf-8") + (tmp_path / "prompts" / "b.md").write_text("do B", encoding="utf-8") + (tmp_path / "prompts" / "template.md").write_text("template", encoding="utf-8") + (tmp_path / "prompts" / "review.md").write_text("review", encoding="utf-8") + (tmp_path / "prompts" / "cost.md").write_text("cost", encoding="utf-8") + (tmp_path / "pipeline.yaml").write_text( + dedent("""\ + name: test + context_dependencies: + a_out: [] + b_out: [a_out] + max_rollbacks: 3 + sub_pipelines: + evaluate_candidate: + max_rollbacks: 2 + iterate_over: a_out.candidates + context_fields_from_parent: [] + steps: + - id: template_generating + conclusion_field: template + forward: reviewing + prompt: prompts/template.md + - id: reviewing + conclusion_field: template + forward: cost_estimating + prompt: prompts/review.md + - id: cost_estimating + conclusion_field: cost + forward: null + prompt: prompts/cost.md + steps: + - id: a + conclusion_field: a_out + forward: b + prompt: prompts/a.md + - id: b + type: parallel_sub_pipeline + sub_pipeline: evaluate_candidate + conclusion_field: b_out + forward: null + prompt: prompts/b.md + """), + encoding="utf-8", + ) + runner = PipelineRunner( + pipeline_dir=tmp_path, + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=FakeSessionStorage(), + session_id="test-session", + cwd=str(tmp_path), + ) + runner.state_machine.advance() # a -> b + runner._parallel_candidates_total = 1 + return runner + + class TestHandleUserInterrupt: @pytest.mark.asyncio async def test_supplement_injects_message(self, pipeline_runner): @@ -808,6 +867,110 @@ def test_candidate_hard_interrupt_uses_persisted_running_candidate_state(self, p assert result is False pipeline_runner._schedule_candidate_restart.assert_called_once_with(verdict) + def test_candidate_restart_to_second_duplicate_writer_restores_first_template(self, tmp_path): + runner = _duplicate_template_pipeline_runner(tmp_path) + mock_task = MagicMock() + mock_task.done.return_value = False + mock_task.cancel = MagicMock() + runner._active_candidates[0] = { + "task": mock_task, + "current_sub_step": "cost_estimating", + "conclusions": {"template": {"body": "reviewed"}, "cost": {"total": 1}}, + "step_conclusions": { + "template_generating": {"body": "generated"}, + "reviewing": {"body": "reviewed"}, + "cost_estimating": {"total": 1}, + }, + "name": "Plan A", + "agent_loop": None, + } + + result = runner.apply_hard_interrupt( + InterruptVerdict( + action="hard_interrupt", + reason="review again", + rollback_target="reviewing", + candidate_scope="candidate:0", + ) + ) + + assert result is False + mock_task.cancel.assert_called_once() + assert runner._pending_candidate_restarts[0].preserved_conclusions == {"template": {"body": "generated"}} + assert runner._execution["candidates"]["0"]["conclusions"] == {"template": {"body": "generated"}} + assert runner._execution["candidates"]["0"]["step_conclusions"] == { + "template_generating": {"body": "generated"} + } + + def test_candidate_restart_to_first_duplicate_writer_drops_reviewed_template(self, tmp_path): + runner = _duplicate_template_pipeline_runner(tmp_path) + mock_task = MagicMock() + mock_task.done.return_value = False + mock_task.cancel = MagicMock() + runner._active_candidates[0] = { + "task": mock_task, + "current_sub_step": "cost_estimating", + "conclusions": {"template": {"body": "reviewed"}, "cost": {"total": 1}}, + "step_conclusions": { + "template_generating": {"body": "generated"}, + "reviewing": {"body": "reviewed"}, + "cost_estimating": {"total": 1}, + }, + "name": "Plan A", + "agent_loop": None, + } + + result = runner.apply_hard_interrupt( + InterruptVerdict( + action="hard_interrupt", + reason="generate again", + rollback_target="template_generating", + candidate_scope="candidate:0", + ) + ) + + assert result is False + mock_task.cancel.assert_called_once() + assert runner._pending_candidate_restarts[0].preserved_conclusions == {} + assert runner._execution["candidates"]["0"]["conclusions"] == {} + assert runner._execution["candidates"]["0"]["step_conclusions"] == {} + + def test_candidate_restart_after_second_duplicate_writer_keeps_reviewed_template(self, tmp_path): + runner = _duplicate_template_pipeline_runner(tmp_path) + mock_task = MagicMock() + mock_task.done.return_value = False + mock_task.cancel = MagicMock() + runner._active_candidates[0] = { + "task": mock_task, + "current_sub_step": "cost_estimating", + "conclusions": {"template": {"body": "reviewed"}, "cost": {"total": 1}}, + "step_conclusions": { + "template_generating": {"body": "generated"}, + "reviewing": {"body": "reviewed"}, + "cost_estimating": {"total": 1}, + }, + "name": "Plan A", + "agent_loop": None, + } + + result = runner.apply_hard_interrupt( + InterruptVerdict( + action="hard_interrupt", + reason="redo cost", + rollback_target="cost_estimating", + candidate_scope="candidate:0", + ) + ) + + assert result is False + mock_task.cancel.assert_called_once() + assert runner._pending_candidate_restarts[0].preserved_conclusions == {"template": {"body": "reviewed"}} + assert runner._execution["candidates"]["0"]["conclusions"] == {"template": {"body": "reviewed"}} + assert runner._execution["candidates"]["0"]["step_conclusions"] == { + "template_generating": {"body": "generated"}, + "reviewing": {"body": "reviewed"}, + } + def test_parent_attempt_creation_preserves_restored_parallel_execution(self, pipeline_runner): _seed_restored_parallel_judge_state(pipeline_runner) diff --git a/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py b/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py index b97c132a..c6f0080f 100644 --- a/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py +++ b/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py @@ -10,6 +10,7 @@ from iac_code.agent.message import ImageBlock, Message, TextBlock, ToolResultBlock from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.pipeline.engine.session import PipelineSession from iac_code.pipeline.engine.types import StepResult, StepStatus from iac_code.services.session_metadata import SESSION_LAYOUT_VERSION_V2, SessionMetadata, write_session_metadata from iac_code.services.session_storage import SessionStorage @@ -41,15 +42,64 @@ def _stub_session_storage(tmp_path: Path): return storage -def _build_runner(tmp_path: Path, *, resume_from_sidecar: bool = False): - pipeline_dir = tmp_path / "pipe" +def _build_runner( + tmp_path: Path, + *, + resume_from_sidecar: bool = False, + prerequisite_resolution: dict | None = None, + pipeline_dir: Path | None = None, +): + if pipeline_dir is None: + pipeline_dir = tmp_path / "pipe" + pipeline_dir.mkdir(exist_ok=True) + (pipeline_dir / "pipeline.yaml").write_text( + yaml.dump( + { + "name": "t", + "context_dependencies": {"x": []}, + "steps": [{"id": "s1", "conclusion_field": "x", "forward": None, "prompt": "prompts/s1.md"}], + } + ), + encoding="utf-8", + ) + prompts_dir = pipeline_dir / "prompts" + prompts_dir.mkdir(exist_ok=True) + (prompts_dir / "s1.md").write_text("step 1", encoding="utf-8") + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + + kwargs = { + "pipeline_dir": pipeline_dir, + "provider_manager": MagicMock(), + "base_tool_registry": MagicMock(), + "session_storage": _stub_session_storage(tmp_path), + "session_id": "sess123", + "cwd": "/proj", + "resume_from_sidecar": resume_from_sidecar, + } + if prerequisite_resolution is not None: + kwargs["prerequisite_resolution"] = prerequisite_resolution + return PipelineRunner(**kwargs) + + +def _build_feature_gated_review_pipeline(tmp_path: Path) -> Path: + pipeline_dir = tmp_path / "feature_gated_pipe" pipeline_dir.mkdir(exist_ok=True) (pipeline_dir / "pipeline.yaml").write_text( yaml.dump( { "name": "t", - "context_dependencies": {"x": []}, - "steps": [{"id": "s1", "conclusion_field": "x", "forward": None, "prompt": "prompts/s1.md"}], + "feature_flags": {"enable_reviewing": {"default": True}}, + "context_dependencies": {"x": [], "review": ["x"]}, + "steps": [ + {"id": "s1", "conclusion_field": "x", "forward": "review", "prompt": "prompts/s1.md"}, + { + "id": "review", + "conclusion_field": "review", + "forward": None, + "prompt": "prompts/review.md", + "enabled_when": "enable_reviewing", + }, + ], } ), encoding="utf-8", @@ -57,17 +107,8 @@ def _build_runner(tmp_path: Path, *, resume_from_sidecar: bool = False): prompts_dir = pipeline_dir / "prompts" prompts_dir.mkdir(exist_ok=True) (prompts_dir / "s1.md").write_text("step 1", encoding="utf-8") - from iac_code.pipeline.engine.pipeline_runner import PipelineRunner - - return PipelineRunner( - pipeline_dir=pipeline_dir, - provider_manager=MagicMock(), - base_tool_registry=MagicMock(), - session_storage=_stub_session_storage(tmp_path), - session_id="sess123", - cwd="/proj", - resume_from_sidecar=resume_from_sidecar, - ) + (prompts_dir / "review.md").write_text("review", encoding="utf-8") + return pipeline_dir def _pipeline_dir(tmp_path: Path) -> Path: @@ -419,6 +460,47 @@ def test_legacy_sidecar_path_not_used(tmp_path): assert ".pipeline" not in sidecar_str # 旧后缀不能出现 +def test_save_running_sync_persists_prerequisite_resolution_metadata(tmp_path): + prerequisite_resolution = { + "feature_flags": {"enable_reviewing": False}, + "decisions": {"infraguard": {"status": "missing"}}, + "env_overrides": {"PATH": "/tmp/tools"}, + } + runner = _build_runner(tmp_path, prerequisite_resolution=prerequisite_resolution) + + runner._save_running_sync("s1", reason="test prerequisites") + + meta = yaml.safe_load(runner.session.meta_path.read_text(encoding="utf-8")) + assert meta["prerequisites"] == prerequisite_resolution + + +def test_pipeline_session_peek_prerequisites_sync_returns_stored_dict_and_ignores_invalid(tmp_path): + missing_session = PipelineSession(tmp_path / "missing") + assert missing_session.peek_prerequisites_sync() == {} + + session = PipelineSession(tmp_path / "pipeline") + session.session_dir.mkdir() + prerequisites = { + "feature_flags": {"enable_reviewing": False}, + "decisions": {"infraguard": {"status": "missing"}}, + "env_overrides": {}, + } + session.meta_path.write_text( + yaml.dump({"status": "running", "prerequisites": prerequisites}), + encoding="utf-8", + ) + assert session.peek_prerequisites_sync() == prerequisites + + session.meta_path.write_text(yaml.dump({"prerequisites": ["invalid"]}), encoding="utf-8") + assert session.peek_prerequisites_sync() == {} + + session.meta_path.write_text("[not, metadata]", encoding="utf-8") + assert session.peek_prerequisites_sync() == {} + + session.meta_path.write_text(":\n", encoding="utf-8") + assert session.peek_prerequisites_sync() == {} + + @pytest.mark.asyncio async def test_resume_from_sidecar_kwarg_restores_state(tmp_path): """问题 4:PipelineRunner(resume_from_sidecar=True) 应在 __init__ 后自动 restore。""" @@ -459,6 +541,103 @@ async def test_resume_from_sidecar_kwarg_restores_state(tmp_path): assert runner2.state_machine.current_step.step_id == "s1" +def test_resume_from_sidecar_uses_stored_feature_flags_before_identity_validation(monkeypatch, tmp_path): + import iac_code.pipeline as pipeline_module + + pipeline_dir = _build_feature_gated_review_pipeline(tmp_path) + storage = _stub_session_storage(tmp_path) + sidecar_dir = storage.session_dir("/proj", "sess123") / "pipeline" + sidecar_dir.mkdir(parents=True, exist_ok=True) + prerequisite_resolution = { + "feature_flags": {"enable_reviewing": False}, + "decisions": {"infraguard": {"status": "missing"}}, + "env_overrides": {}, + } + (sidecar_dir / "meta.yaml").write_text( + yaml.dump( + { + "pipeline_name": "t", + "status": "running", + "current_step": "s1", + "step_ids": ["s1"], + "sub_pipeline_step_ids": {}, + "pipeline_fingerprint": hashlib.sha256((pipeline_dir / "pipeline.yaml").read_bytes()).hexdigest(), + "state_machine": { + "current_index": 0, + "rollback_count": 0, + "interrupt_rollback_count": 0, + "step_statuses": {"s1": "running"}, + }, + "updated_at": 0.0, + "reason": "seeded with review disabled", + "prerequisites": prerequisite_resolution, + } + ), + encoding="utf-8", + ) + (sidecar_dir / "context.yaml").write_text(yaml.dump({}), encoding="utf-8") + monkeypatch.setattr(pipeline_module, "discover_pipelines", lambda: {"feature_review": pipeline_dir}) + + runner = pipeline_module.create_pipeline( + "feature_review", + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=storage, + session_id="sess123", + cwd="/proj", + resume_from_sidecar=True, + ) + + assert [step.step_id for step in runner._loaded.steps] == ["s1"] + assert runner._loaded.feature_flags["enable_reviewing"] is False + assert runner.sidecar_restore_result is not None + assert runner.sidecar_restore_result.ok is True + assert runner.sidecar_restore_result.reason != "pipeline_identity_mismatch" + + +def test_direct_resume_from_sidecar_uses_stored_feature_flags_before_identity_validation(tmp_path): + pipeline_dir = _build_feature_gated_review_pipeline(tmp_path) + storage = _stub_session_storage(tmp_path) + sidecar_dir = storage.session_dir("/proj", "sess123") / "pipeline" + sidecar_dir.mkdir(parents=True, exist_ok=True) + prerequisite_resolution = { + "feature_flags": {"enable_reviewing": False}, + "decisions": {"infraguard": {"status": "missing"}}, + "env_overrides": {}, + } + (sidecar_dir / "meta.yaml").write_text( + yaml.dump( + { + "pipeline_name": "t", + "status": "running", + "current_step": "s1", + "step_ids": ["s1"], + "sub_pipeline_step_ids": {}, + "pipeline_fingerprint": hashlib.sha256((pipeline_dir / "pipeline.yaml").read_bytes()).hexdigest(), + "state_machine": { + "current_index": 0, + "rollback_count": 0, + "interrupt_rollback_count": 0, + "step_statuses": {"s1": "running"}, + }, + "updated_at": 0.0, + "reason": "seeded with review disabled", + "prerequisites": prerequisite_resolution, + } + ), + encoding="utf-8", + ) + (sidecar_dir / "context.yaml").write_text(yaml.dump({}), encoding="utf-8") + + runner = _build_runner(tmp_path, pipeline_dir=pipeline_dir, resume_from_sidecar=True) + + assert [step.step_id for step in runner._loaded.steps] == ["s1"] + assert runner._loaded.feature_flags["enable_reviewing"] is False + assert runner.sidecar_restore_result is not None + assert runner.sidecar_restore_result.ok is True + assert runner.sidecar_restore_result.reason != "pipeline_identity_mismatch" + + def test_resume_from_sidecar_kwarg_exposes_failed_restore_result(tmp_path): runner = _build_two_step_runner(tmp_path) sidecar_dir = runner.session.session_dir diff --git a/tests/pipeline/engine/test_prerequisites.py b/tests/pipeline/engine/test_prerequisites.py new file mode 100644 index 00000000..bbbc12c9 --- /dev/null +++ b/tests/pipeline/engine/test_prerequisites.py @@ -0,0 +1,1622 @@ +import hashlib +import json +import os +import signal +import subprocess +import sys +import time +import urllib.error +from dataclasses import is_dataclass +from pathlib import Path + +import pytest +import yaml + +from iac_code.pipeline.engine import prerequisites as prereq_module +from iac_code.pipeline.engine.prerequisites import ( + CommandResult, + PrerequisiteProgress, + inspect_prerequisites, + prepare_prerequisites, +) + + +def _infraguard_prereqs(): + return { + "infraguard": { + "command": "infraguard", + "required_by_flags": ["enable_reviewing"], + "on_missing": {"repl": "prompt_install", "non_interactive": "disable_feature"}, + "installers": [ + { + "id": "homebrew", + "platforms": ["darwin", "linux"], + "requires_commands": ["brew"], + "commands": [ + ["brew", "tap", "aliyun/infraguard", "https://github.com/aliyun/infraguard"], + ["brew", "install", "infraguard"], + ], + }, + { + "id": "go-install", + "platforms": ["darwin", "linux", "windows"], + "requires_commands": ["go"], + "env": {"GOPROXY": "https://mirrors.aliyun.com/goproxy/,direct"}, + "commands": [ + [ + "go", + "install", + "github.com/aliyun/infraguard/cmd/infraguard@0a0f3e2427d883c4d5cbb6f1a4b7ebd78a029b43", + ] + ], + "path_hints": [ + {"kind": "command_output", "command": ["go", "env", "GOBIN"]}, + {"kind": "command_output", "command": ["go", "env", "GOPATH"], "append": "bin"}, + ], + }, + ], + "post_install": {"commands": [["infraguard", "policy", "update"]]}, + } + } + + +def _infraguard_prereqs_with_version_check(): + prereqs = _infraguard_prereqs() + prereqs["infraguard"]["version_check"] = { + "command": ["infraguard", "version"], + "minimum": "0.10.1", + "pattern": r"InfraGuard:\s*(?P\d+\.\d+\.\d+)", + } + return prereqs + + +def _infraguard_prereqs_with_latest_go_install_target(): + prereqs = _infraguard_prereqs() + go_installer = next( + installer for installer in prereqs["infraguard"]["installers"] if installer["id"] == "go-install" + ) + go_installer["commands"] = [ + [ + "go", + "install", + { + "kind": "go-install-latest-github-tag", + "repo": "aliyun/infraguard", + "module": "github.com/aliyun/infraguard/cmd/infraguard", + "tag_prefix": "v", + "fallback_ref": "0a0f3e2427d883c4d5cbb6f1a4b7ebd78a029b43", + }, + ] + ] + return prereqs + + +def _direct_binary_prereqs(source_url: str, install_dir: str, sha256: str): + return { + "infraguard": { + "command": "infraguard", + "required_by_flags": ["enable_reviewing"], + "on_missing": {"repl": "prompt_install", "non_interactive": "disable_feature"}, + "installers": [ + { + "id": "direct-binary", + "platforms": ["darwin", "linux", "windows"], + "download": { + "install_dir": install_dir, + "installed_name": "infraguard", + "assets": [ + { + "platforms": ["darwin"], + "architectures": ["arm64"], + "filename": "infraguard-v0.10.0-darwin-arm64", + "urls": [source_url], + "sha256": sha256, + } + ], + }, + } + ], + "post_install": {"commands": [["infraguard", "policy", "update"]]}, + } + } + + +def test_inspect_disables_required_flag_when_command_missing_and_non_interactive_disables_feature(): + resolution = inspect_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + command_exists=lambda _command: None, + ) + + decision = resolution.decisions["infraguard"] + assert resolution.feature_flags == {"enable_reviewing": False} + assert decision.status == "disabled_feature" + assert decision.required_flags == ["enable_reviewing"] + assert decision.resolved_path is None + + +def test_inspect_keeps_flag_enabled_when_command_exists(): + resolution = inspect_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + command_exists=lambda command: f"/opt/bin/{command}", + ) + + decision = resolution.decisions["infraguard"] + assert resolution.feature_flags == {"enable_reviewing": True} + assert decision.status == "available" + assert decision.resolved_path == "/opt/bin/infraguard" + + +def test_inspect_disables_required_flag_when_existing_command_fails_version_check(): + resolution = inspect_prerequisites( + _infraguard_prereqs_with_version_check(), + feature_flags={"enable_reviewing": True}, + command_exists=lambda command: f"/opt/bin/{command}", + run_command=lambda command, env=None: CommandResult( + command=command, + returncode=0, + stdout="InfraGuard: 0.6.0\n", + stderr="", + ), + ) + + decision = resolution.decisions["infraguard"] + assert resolution.feature_flags == {"enable_reviewing": False} + assert decision.status == "disabled_feature" + assert decision.resolved_path == "/opt/bin/infraguard" + assert "0.6.0" in decision.message + + +def test_inspect_passes_default_timeout_to_version_check(): + observed_timeouts: list[float | None] = [] + + def run_command(command, env=None, timeout_seconds=None): + observed_timeouts.append(timeout_seconds) + return CommandResult(command=command, returncode=0, stdout="InfraGuard: 0.10.1\n", stderr="") + + resolution = inspect_prerequisites( + _infraguard_prereqs_with_version_check(), + feature_flags={"enable_reviewing": True}, + command_exists=lambda command: f"/opt/bin/{command}", + run_command=run_command, + ) + + assert resolution.decisions["infraguard"].status == "available" + assert observed_timeouts == [30.0] + + +def test_prepare_offers_only_platform_matching_installers_with_available_required_commands(): + offered_installer_ids = [] + + def choose_installer(_name, installers): + offered_installer_ids.extend(installer.id for installer in installers) + return None + + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="linux", + command_exists=lambda command: command == "brew", + choose_installer=choose_installer, + ) + + assert offered_installer_ids == ["homebrew"] + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "declined_or_unavailable" + + +def test_prepare_preserves_installer_display_metadata_for_repl_choice(): + prereqs = _infraguard_prereqs() + prereqs["infraguard"]["installers"][0]["display_key"] = "homebrew" + prereqs["infraguard"]["installers"][0]["display_name"] = "Homebrew" + offered_installers = [] + + def choose_installer(_name, installers): + offered_installers.extend(installers) + return None + + prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="linux", + command_exists=lambda command: command == "brew", + choose_installer=choose_installer, + ) + + assert offered_installers[0].display_key == "homebrew" + assert offered_installers[0].display_name == "Homebrew" + + +def test_prepare_accepted_install_runs_installer_commands_then_policy_update_and_keeps_review_enabled(): + available_commands = {"brew": "/usr/local/bin/brew"} + commands_run = [] + + def command_exists(command): + return available_commands.get(command) + + def run_command(command, env=None): + commands_run.append(command) + if command == ["brew", "install", "infraguard"]: + available_commands["infraguard"] = "/usr/local/bin/infraguard" + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + command_exists=command_exists, + run_command=run_command, + choose_installer=lambda _name, _installers: "homebrew", + ) + + assert commands_run == [ + ["brew", "tap", "aliyun/infraguard", "https://github.com/aliyun/infraguard"], + ["brew", "install", "infraguard"], + ["infraguard", "policy", "update"], + ] + assert resolution.feature_flags == {"enable_reviewing": True} + assert resolution.decisions["infraguard"].status == "available" + assert resolution.decisions["infraguard"].installer_id == "homebrew" + + +def test_prepare_passes_configured_command_timeouts_to_installer_and_post_install(): + prereqs = _infraguard_prereqs() + homebrew = prereqs["infraguard"]["installers"][0] + homebrew["timeout_seconds"] = 321 + prereqs["infraguard"]["post_install"]["timeout_seconds"] = 45 + available_commands = {"brew": "/usr/local/bin/brew"} + observed_timeouts: list[tuple[list[str], float | None]] = [] + + def command_exists(command): + return available_commands.get(command) + + def run_command(command, env=None, on_output=None, timeout_seconds=None): + observed_timeouts.append((command, timeout_seconds)) + if command == ["brew", "install", "infraguard"]: + available_commands["infraguard"] = "/usr/local/bin/infraguard" + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + command_exists=command_exists, + run_command=run_command, + choose_installer=lambda _name, _installers: "homebrew", + ) + + assert resolution.decisions["infraguard"].status == "available" + assert observed_timeouts == [ + (["brew", "tap", "aliyun/infraguard", "https://github.com/aliyun/infraguard"], 321.0), + (["brew", "install", "infraguard"], 321.0), + (["infraguard", "policy", "update"], 45.0), + ] + + +def test_default_run_command_returns_timeout_result_and_terminates_child(): + result = prereq_module._default_run_command( + [sys.executable, "-c", "import time; time.sleep(10)"], + timeout_seconds=0.01, + ) + + assert result.returncode == 124 + assert "timed out" in result.stderr + + +def test_default_run_command_timeout_terminates_descendant_process(tmp_path): + ticks_path = tmp_path / "ticks.txt" + pid_path = tmp_path / "child.pid" + child_code = ( + "import pathlib, sys, time\n" + "ticks = pathlib.Path(sys.argv[1])\n" + "pathlib.Path(sys.argv[2]).write_text(str(__import__('os').getpid()), encoding='utf-8')\n" + "while True:\n" + " with ticks.open('a', encoding='utf-8') as handle:\n" + " handle.write('x')\n" + " time.sleep(0.05)\n" + ) + parent_code = ( + "import pathlib, subprocess, sys, time\n" + "ticks = pathlib.Path(sys.argv[1])\n" + "subprocess.Popen([sys.executable, '-c', sys.argv[3], sys.argv[1], sys.argv[2]])\n" + "deadline = time.time() + 3\n" + "while not ticks.exists() and time.time() < deadline:\n" + " time.sleep(0.01)\n" + "time.sleep(10)\n" + ) + + child_pid = None + try: + result = prereq_module._default_run_command( + [sys.executable, "-c", parent_code, str(ticks_path), str(pid_path), child_code], + timeout_seconds=0.5, + ) + assert result.returncode == 124 + + size_after_timeout = ticks_path.stat().st_size + time.sleep(0.3) + assert ticks_path.stat().st_size == size_after_timeout + finally: + if pid_path.exists(): + child_pid = int(pid_path.read_text(encoding="utf-8")) + if child_pid is not None: + _terminate_test_process(child_pid) + + +def _terminate_test_process(pid: int) -> None: + if sys.platform == "win32": + subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True, check=False) + return + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + return + + +def test_prepare_direct_binary_installer_downloads_without_brew_or_go_and_runs_policy_update(tmp_path): + asset = tmp_path / "infraguard-v0.10.0-darwin-arm64" + payload = b"#!/bin/sh\necho infraguard\n" + asset.write_bytes(payload) + install_dir = tmp_path / "bin" + progress_events: list[PrerequisiteProgress] = [] + post_install_paths: list[str] = [] + + def command_exists(_command): + return None + + def run_command(command, env=None): + if command == ["infraguard", "policy", "update"]: + post_install_paths.append((env or {}).get("PATH", "")) + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + resolution = prepare_prerequisites( + _direct_binary_prereqs(asset.as_uri(), str(install_dir), hashlib.sha256(payload).hexdigest()), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=command_exists, + run_command=run_command, + choose_installer=lambda _name, _installers: "direct-binary", + progress_handler=progress_events.append, + ) + + installed = install_dir / "infraguard" + assert installed.read_bytes() == payload + assert os.access(installed, os.X_OK) + assert resolution.feature_flags == {"enable_reviewing": True} + assert resolution.decisions["infraguard"].status == "available" + assert resolution.decisions["infraguard"].resolved_path == str(installed) + assert resolution.env_overrides["PATH"].split(os.pathsep)[0] == str(install_dir) + assert post_install_paths == [resolution.env_overrides["PATH"]] + assert any(event.phase == "download" and event.status == "output" for event in progress_events) + assert any(event.phase == "post_install" and event.status == "succeeded" for event in progress_events) + + +def test_prepare_finds_existing_direct_binary_install_dir_before_prompting(tmp_path): + install_dir = tmp_path / "bin" + install_dir.mkdir() + installed = install_dir / "infraguard" + installed.write_text("#!/bin/sh\n", encoding="utf-8") + installed.chmod(0o755) + + def choose_installer(_name, _installers): + raise AssertionError("already installed infraguard should be found before prompting") + + resolution = prepare_prerequisites( + _direct_binary_prereqs("https://example.com/infraguard", str(install_dir), "0" * 64), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=choose_installer, + ) + + assert resolution.feature_flags == {"enable_reviewing": True} + assert resolution.decisions["infraguard"].status == "available" + assert resolution.decisions["infraguard"].resolved_path == str(installed) + assert resolution.decisions["infraguard"].installer_id == "direct-binary" + assert resolution.env_overrides["PATH"].split(os.pathsep)[0] == str(install_dir) + + +def test_prepare_direct_binary_installer_reports_download_percent_when_content_length_is_known(tmp_path): + asset = tmp_path / "infraguard-v0.10.0-darwin-arm64" + payload = b"x" * 1536 + asset.write_bytes(payload) + install_dir = tmp_path / "bin" + progress_events: list[PrerequisiteProgress] = [] + + resolution = prepare_prerequisites( + _direct_binary_prereqs(asset.as_uri(), str(install_dir), hashlib.sha256(payload).hexdigest()), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=lambda _name, _installers: "direct-binary", + progress_handler=progress_events.append, + ) + + download_events = [event for event in progress_events if event.phase == "download" and event.status == "output"] + assert resolution.decisions["infraguard"].status == "available" + assert download_events[-1].message == "Downloading infraguard-v0.10.0-darwin-arm64: 100% (1.5 KB / 1.5 KB)" + assert download_events[-1].downloaded_bytes == len(payload) + assert download_events[-1].total_bytes == len(payload) + + +def test_download_progress_reports_downloaded_size_when_total_is_unknown(): + progress_events: list[PrerequisiteProgress] = [] + + prereq_module._emit_download_progress( + progress_events.append, + name="infraguard", + installer_id="direct-binary", + command=["download", "infraguard"], + filename="infraguard", + downloaded=1536, + total=0, + ) + + assert progress_events[0].message == "Downloading infraguard: 1.5 KB downloaded" + assert progress_events[0].downloaded_bytes == 1536 + assert progress_events[0].total_bytes is None + + +def test_prepare_direct_binary_installer_cleans_partial_download_on_keyboard_interrupt(tmp_path, monkeypatch): + install_dir = tmp_path / "bin" + prereqs = _direct_binary_prereqs( + "https://example.com/infraguard-v0.10.0-darwin-arm64", + str(install_dir), + "0" * 64, + ) + + class InterruptingResponse: + headers = {"Content-Length": "4096"} + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def read(self, _size): + raise KeyboardInterrupt + + monkeypatch.setattr( + prereq_module.urllib.request, + "urlopen", + lambda *args, **kwargs: InterruptingResponse(), + ) + + with pytest.raises(KeyboardInterrupt): + prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=lambda _name, _installers: "direct-binary", + ) + + assert not (install_dir / ".infraguard.download").exists() + + +def test_prepare_direct_binary_installer_does_not_apply_download_timeout_by_default(tmp_path, monkeypatch): + install_dir = tmp_path / "bin" + payload = b"binary" + prereqs = _direct_binary_prereqs( + "https://example.com/infraguard-v0.10.0-darwin-arm64", + str(install_dir), + hashlib.sha256(payload).hexdigest(), + ) + observed_timeouts = [] + + class Response: + headers = {"Content-Length": str(len(payload))} + + def __init__(self): + self._sent = False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def read(self, _size): + if self._sent: + return b"" + self._sent = True + return payload + + def fake_urlopen(_url, *, timeout=None): + observed_timeouts.append(timeout) + return Response() + + monkeypatch.setattr(prereq_module.urllib.request, "urlopen", fake_urlopen) + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=lambda _name, _installers: "direct-binary", + ) + + assert observed_timeouts == [None] + assert resolution.decisions["infraguard"].status == "available" + + +def test_prepare_direct_binary_installer_uses_configured_download_timeout(tmp_path, monkeypatch): + install_dir = tmp_path / "bin" + payload = b"binary" + prereqs = _direct_binary_prereqs( + "https://example.com/infraguard-v0.10.0-darwin-arm64", + str(install_dir), + hashlib.sha256(payload).hexdigest(), + ) + prereqs["infraguard"]["installers"][0]["download"]["timeout_seconds"] = 120 + observed_timeouts = [] + + class Response: + headers = {"Content-Length": str(len(payload))} + + def __init__(self): + self._sent = False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def read(self, _size): + if self._sent: + return b"" + self._sent = True + return payload + + def fake_urlopen(_url, *, timeout=None): + observed_timeouts.append(timeout) + return Response() + + monkeypatch.setattr(prereq_module.urllib.request, "urlopen", fake_urlopen) + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=lambda _name, _installers: "direct-binary", + ) + + assert observed_timeouts == [120.0] + assert resolution.decisions["infraguard"].status == "available" + + +def test_prepare_direct_binary_installer_reports_incomplete_download_before_sha_mismatch(tmp_path, monkeypatch): + install_dir = tmp_path / "bin" + payload = b"partial" + prereqs = _direct_binary_prereqs( + "https://example.com/infraguard-v0.10.0-darwin-arm64", + str(install_dir), + "0" * 64, + ) + + class Response: + headers = {"Content-Length": "100"} + + def __init__(self): + self._sent = False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def read(self, _size): + if self._sent: + return b"" + self._sent = True + return payload + + monkeypatch.setattr( + prereq_module.urllib.request, + "urlopen", + lambda *args, **kwargs: Response(), + ) + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=lambda _name, _installers: "direct-binary", + ) + + message = resolution.decisions["infraguard"].message + assert "incomplete download for infraguard-v0.10.0-darwin-arm64" in message + assert "expected 100 B, got 7 B" in message + assert "sha256 mismatch" not in message + + +def test_prepare_direct_binary_installer_can_read_download_url_from_environment(tmp_path, monkeypatch): + asset = tmp_path / "infraguard-v0.10.0-darwin-arm64" + payload = b"binary" + asset.write_bytes(payload) + install_dir = tmp_path / "bin" + prereqs = _direct_binary_prereqs("https://invalid.example/infraguard", str(install_dir), "") + prereqs["infraguard"]["installers"][0]["download"]["assets"][0]["urls"] = [ + {"env": "IAC_CODE_TEST_INFRAGUARD_URL"}, + "https://invalid.example/infraguard", + ] + prereqs["infraguard"]["installers"][0]["download"]["assets"][0]["sha256"] = hashlib.sha256(payload).hexdigest() + monkeypatch.setenv("IAC_CODE_TEST_INFRAGUARD_URL", asset.as_uri()) + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=lambda _name, _installers: "direct-binary", + ) + + assert (install_dir / "infraguard").read_bytes() == payload + assert resolution.decisions["infraguard"].status == "available" + + +def test_prepare_hides_direct_binary_installer_when_no_asset_matches_architecture(tmp_path): + asset = tmp_path / "infraguard-v0.10.0-darwin-arm64" + asset.write_bytes(b"binary") + offered_installer_ids = [] + + def choose_installer(_name, installers): + offered_installer_ids.extend(installer.id for installer in installers) + return None + + resolution = prepare_prerequisites( + _direct_binary_prereqs(asset.as_uri(), str(tmp_path / "bin"), ""), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="amd64", + command_exists=lambda _command: None, + choose_installer=choose_installer, + ) + + assert offered_installer_ids == [] + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "declined_or_unavailable" + + +def test_prepare_existing_infraguard_on_path_does_not_run_installer_or_post_install(): + def unexpected_run_command(command, env=None): + raise AssertionError(f"unexpected command: {command}") + + def unexpected_choose_installer(_name, _installers): + raise AssertionError("installer should not be chosen") + + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + command_exists=lambda command: f"/usr/bin/{command}" if command == "infraguard" else None, + run_command=unexpected_run_command, + choose_installer=unexpected_choose_installer, + ) + + assert resolution.feature_flags == {"enable_reviewing": True} + assert resolution.decisions["infraguard"].status == "available" + assert resolution.decisions["infraguard"].resolved_path == "/usr/bin/infraguard" + + +def test_prepare_install_succeeds_but_post_install_fails_disables_review_for_run(): + available_commands = {"brew": "/usr/local/bin/brew"} + + def command_exists(command): + return available_commands.get(command) + + def run_command(command, env=None): + if command == ["brew", "install", "infraguard"]: + available_commands["infraguard"] = "/usr/local/bin/infraguard" + if command == ["infraguard", "policy", "update"]: + return CommandResult(command=command, returncode=1, stdout="", stderr="policy failed") + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + command_exists=command_exists, + run_command=run_command, + choose_installer=lambda _name, _installers: "homebrew", + ) + + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "post_install_failed" + + +def test_prepare_installer_commands_receive_configured_environment_without_persisting_to_post_install(): + prereqs = _infraguard_prereqs() + prereqs["infraguard"]["installers"][0]["env"] = { + "HOMEBREW_NO_AUTO_UPDATE": "1", + "HOMEBREW_NO_ENV_HINTS": "1", + } + available_commands = {"brew": "/usr/local/bin/brew"} + brew_envs = [] + post_install_envs = [] + + def command_exists(command): + return available_commands.get(command) + + def run_command(command, env=None): + if command[:1] == ["brew"]: + brew_envs.append(dict(env or {})) + if command == ["brew", "install", "infraguard"]: + available_commands["infraguard"] = "/usr/local/bin/infraguard" + if command == ["infraguard", "policy", "update"]: + post_install_envs.append(dict(env or {})) + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + command_exists=command_exists, + run_command=run_command, + choose_installer=lambda _name, _installers: "homebrew", + ) + + assert resolution.decisions["infraguard"].status == "available" + assert brew_envs + assert all(env["HOMEBREW_NO_AUTO_UPDATE"] == "1" for env in brew_envs) + assert all(env["HOMEBREW_NO_ENV_HINTS"] == "1" for env in brew_envs) + assert post_install_envs == [{}] + + +def test_prepare_install_failure_message_is_truncated_to_actionable_tail(): + available_commands = {"brew": "/usr/local/bin/brew"} + noisy_stderr = "\n".join( + [ + "==> Auto-updating Homebrew...", + *(f"formula update line {index}" for index in range(200)), + "fatal: early EOF", + "fatal: fetch-pack: invalid index-pack output", + ] + ) + + def run_command(command, env=None): + return CommandResult(command=command, returncode=128, stdout="", stderr=noisy_stderr) + + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + command_exists=lambda command: available_commands.get(command), + run_command=run_command, + choose_installer=lambda _name, _installers: "homebrew", + ) + + message = resolution.decisions["infraguard"].message + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "install_failed" + assert "fatal: early EOF" in message + assert "invalid index-pack output" in message + assert "formula update line 0" not in message + assert len(message) < 1600 + + +def test_prepare_run_command_exception_disables_review_instead_of_escaping(): + available_commands = {"brew": "/usr/local/bin/brew"} + + def run_command(command, env=None): + raise OSError("exec format error") + + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + command_exists=lambda command: available_commands.get(command), + run_command=run_command, + choose_installer=lambda _name, _installers: "homebrew", + ) + + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "install_failed" + assert "exec format error" in resolution.decisions["infraguard"].message + + +def test_prepare_hides_direct_binary_installer_when_sha256_is_missing(tmp_path): + asset = tmp_path / "infraguard-v0.10.0-darwin-arm64" + asset.write_bytes(b"binary") + install_dir = tmp_path / "bin" + offered_installer_ids = [] + + def choose_installer(_name, installers): + offered_installer_ids.extend(installer.id for installer in installers) + return "direct-binary" + + resolution = prepare_prerequisites( + _direct_binary_prereqs(asset.as_uri(), str(install_dir), ""), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=choose_installer, + ) + + assert offered_installer_ids == [] + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "declined_or_unavailable" + + +def test_prepare_direct_binary_installer_reports_invalid_url_as_install_failure(tmp_path): + secret_url = "not a valid url?token=secret" + prereqs = _direct_binary_prereqs(secret_url, str(tmp_path / "bin"), "0" * 64) + progress_events: list[PrerequisiteProgress] = [] + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=lambda _name, _installers: "direct-binary", + progress_handler=progress_events.append, + ) + + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "install_failed" + assert "download failed: ValueError" in resolution.decisions["infraguard"].message + assert "secret" not in resolution.decisions["infraguard"].message + assert all("secret" not in " ".join(event.command) for event in progress_events) + + +def test_prepare_direct_binary_installer_reports_invalid_http_url_without_leaking_token(tmp_path): + secret_url = "http://example.com:bad?token=secret" + prereqs = _direct_binary_prereqs(secret_url, str(tmp_path / "bin"), "0" * 64) + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=lambda _name, _installers: "direct-binary", + ) + + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "install_failed" + assert "secret" not in resolution.decisions["infraguard"].message + assert "download failed: InvalidURL" in resolution.decisions["infraguard"].message + + +def test_prepare_direct_binary_installer_redacts_path_query_url_fragments(tmp_path): + secret_url = "http://example.com/pa th?token=secret" + prereqs = _direct_binary_prereqs(secret_url, str(tmp_path / "bin"), "0" * 64) + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=lambda _name, _installers: "direct-binary", + ) + + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "install_failed" + assert "token=secret" not in resolution.decisions["infraguard"].message + assert "pa th" not in resolution.decisions["infraguard"].message + + +def test_prepare_direct_binary_installer_redacts_url_userinfo(tmp_path): + secret_url = "http://user:pass@example.com/infraguard" + prereqs = _direct_binary_prereqs(secret_url, str(tmp_path / "bin"), "0" * 64) + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=lambda _name, _installers: "direct-binary", + ) + + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "install_failed" + assert "user" not in resolution.decisions["infraguard"].message + assert "pass" not in resolution.decisions["infraguard"].message + + +def test_prepare_direct_binary_installer_ignores_cleanup_failure_after_download_error(tmp_path, monkeypatch): + asset = tmp_path / "infraguard-v0.10.0-darwin-arm64" + asset.write_bytes(b"binary") + prereqs = _direct_binary_prereqs(asset.as_uri(), str(tmp_path / "bin"), "0" * 64) + + original_unlink = Path.unlink + + def fail_unlink(self, *args, **kwargs): + if self.name.startswith(".infraguard.download"): + raise OSError("cleanup failed") + return original_unlink(self, *args, **kwargs) + + monkeypatch.setattr("iac_code.pipeline.engine.prerequisites.Path.unlink", fail_unlink) + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=lambda _name, _installers: "direct-binary", + ) + + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "install_failed" + assert "sha256 mismatch" in resolution.decisions["infraguard"].message + + +def test_prepare_direct_binary_installer_reports_install_dir_creation_failure(tmp_path, monkeypatch): + prereqs = _direct_binary_prereqs((tmp_path / "missing").as_uri(), str(tmp_path / "bin"), "0" * 64) + + def fail_mkdir(self, *args, **kwargs): + raise OSError("permission denied") + + monkeypatch.setattr("iac_code.pipeline.engine.prerequisites.Path.mkdir", fail_mkdir) + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + run_command=lambda command, env=None: CommandResult(command=command, returncode=0, stdout="", stderr=""), + choose_installer=lambda _name, _installers: "direct-binary", + ) + + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "install_failed" + assert "permission denied" in resolution.decisions["infraguard"].message + + +def test_prepare_hides_direct_binary_installer_when_sha256_env_is_unset(tmp_path): + asset = tmp_path / "infraguard-v0.10.0-darwin-arm64" + asset.write_bytes(b"binary") + prereqs = _direct_binary_prereqs(asset.as_uri(), str(tmp_path / "bin"), "") + prereqs["infraguard"]["installers"][0]["download"]["assets"][0]["sha256"] = { + "env": "IAC_CODE_TEST_INFRAGUARD_SHA256" + } + offered_installer_ids = [] + + def choose_installer(_name, installers): + offered_installer_ids.extend(installer.id for installer in installers) + return None + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="darwin", + platform_machine="arm64", + command_exists=lambda _command: None, + choose_installer=choose_installer, + ) + + assert offered_installer_ids == [] + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "declined_or_unavailable" + + +def test_prepare_go_install_path_hints_resolve_executable_and_use_path_for_post_install(tmp_path): + gopath_bin = tmp_path / "bin" + gopath_bin.mkdir() + executable = gopath_bin / "infraguard" + install_envs = [] + post_install_env_paths = [] + + def command_exists(command): + return "/usr/local/bin/go" if command == "go" else None + + def run_command(command, env=None): + if command == [ + "go", + "install", + "github.com/aliyun/infraguard/cmd/infraguard@0a0f3e2427d883c4d5cbb6f1a4b7ebd78a029b43", + ]: + install_envs.append(dict(env or {})) + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o755) + if command == ["go", "env", "GOPATH"]: + return CommandResult(command=command, returncode=0, stdout=str(tmp_path), stderr="") + if command == ["infraguard", "policy", "update"]: + post_install_env_paths.append((env or {}).get("PATH", "")) + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="linux", + command_exists=command_exists, + run_command=run_command, + choose_installer=lambda _name, _installers: "go-install", + ) + + assert resolution.feature_flags == {"enable_reviewing": True} + assert resolution.decisions["infraguard"].status == "available" + assert resolution.decisions["infraguard"].resolved_path == str(executable) + assert resolution.env_overrides["PATH"].split(os.pathsep)[0] == str(gopath_bin) + assert install_envs + assert install_envs[0]["GOPROXY"] == "https://mirrors.aliyun.com/goproxy/,direct" + assert post_install_env_paths == [resolution.env_overrides["PATH"]] + + +def test_prepare_go_install_resolves_latest_cli_release_commit_from_github_tags(tmp_path, monkeypatch): + gopath_bin = tmp_path / "bin" + gopath_bin.mkdir() + executable = gopath_bin / "infraguard" + commands_run = [] + + class Response: + def __init__(self, payload): + self._payload = json.dumps(payload).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def read(self): + return self._payload + + def fake_urlopen(url, *, timeout=None): + url = str(url) + if url.endswith("/git/matching-refs/tags/v"): + return Response( + [ + {"ref": "refs/tags/v0.9.0", "object": {"type": "tag", "sha": "tag-sha-090"}}, + {"ref": "refs/tags/v0.10.1", "object": {"type": "tag", "sha": "tag-sha-101"}}, + ] + ) + if url.endswith("/git/tags/tag-sha-101"): + return Response({"object": {"type": "commit", "sha": "commit-sha-101"}}) + raise AssertionError(f"unexpected url: {url}") + + monkeypatch.setattr(prereq_module.subprocess, "run", lambda *args, **kwargs: (_ for _ in ()).throw(OSError())) + monkeypatch.setattr(prereq_module.urllib.request, "urlopen", fake_urlopen) + + def command_exists(command): + return "/usr/local/bin/go" if command == "go" else None + + def run_command(command, env=None): + commands_run.append(command) + if command == [ + "go", + "install", + "github.com/aliyun/infraguard/cmd/infraguard@commit-sha-101", + ]: + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o755) + if command == ["go", "env", "GOPATH"]: + return CommandResult(command=command, returncode=0, stdout=str(tmp_path), stderr="") + if command == ["infraguard", "policy", "update"]: + return CommandResult(command=command, returncode=0, stdout="", stderr="") + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + resolution = prepare_prerequisites( + _infraguard_prereqs_with_latest_go_install_target(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="linux", + command_exists=command_exists, + run_command=run_command, + choose_installer=lambda _name, _installers: "go-install", + ) + + assert [ + "go", + "install", + "github.com/aliyun/infraguard/cmd/infraguard@commit-sha-101", + ] in commands_run + assert resolution.decisions["infraguard"].status == "available" + + +def test_latest_go_install_target_prefers_git_ls_remote_over_github_api(monkeypatch): + def fake_run(command, capture_output, text, check, timeout): + assert command == ["git", "ls-remote", "--tags", "https://github.com/aliyun/infraguard.git", "refs/tags/v*"] + + return prereq_module.subprocess.CompletedProcess( + command, + 0, + stdout=( + "tag-sha-090\trefs/tags/v0.9.0\n" + "commit-sha-090\trefs/tags/v0.9.0^{}\n" + "tag-sha-101\trefs/tags/v0.10.1\n" + "commit-sha-101\trefs/tags/v0.10.1^{}\n" + ), + stderr="", + ) + + def unexpected_urlopen(_url, *, timeout=None): + raise AssertionError("GitHub API should not be called when git ls-remote succeeds") + + monkeypatch.setattr(prereq_module.subprocess, "run", fake_run) + monkeypatch.setattr(prereq_module.urllib.request, "urlopen", unexpected_urlopen) + + target = prereq_module._resolve_latest_github_tag_go_install_target( + { + "kind": "go-install-latest-github-tag", + "repo": "aliyun/infraguard", + "module": "github.com/aliyun/infraguard/cmd/infraguard", + "tag_prefix": "v", + "fallback_ref": "fallback", + } + ) + + assert target == "github.com/aliyun/infraguard/cmd/infraguard@commit-sha-101" + + +def test_prepare_go_install_uses_stable_fallback_when_latest_cli_release_detection_fails(tmp_path, monkeypatch): + gopath_bin = tmp_path / "bin" + gopath_bin.mkdir() + executable = gopath_bin / "infraguard" + commands_run = [] + + def fake_urlopen(_url, *, timeout=None): + raise urllib.error.URLError("offline") + + monkeypatch.setattr(prereq_module.subprocess, "run", lambda *args, **kwargs: (_ for _ in ()).throw(OSError())) + monkeypatch.setattr(prereq_module.urllib.request, "urlopen", fake_urlopen) + + def command_exists(command): + return "/usr/local/bin/go" if command == "go" else None + + def run_command(command, env=None): + commands_run.append(command) + if command == [ + "go", + "install", + "github.com/aliyun/infraguard/cmd/infraguard@0a0f3e2427d883c4d5cbb6f1a4b7ebd78a029b43", + ]: + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o755) + if command == ["go", "env", "GOPATH"]: + return CommandResult(command=command, returncode=0, stdout=str(tmp_path), stderr="") + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + resolution = prepare_prerequisites( + _infraguard_prereqs_with_latest_go_install_target(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="linux", + command_exists=command_exists, + run_command=run_command, + choose_installer=lambda _name, _installers: "go-install", + ) + + assert [ + "go", + "install", + "github.com/aliyun/infraguard/cmd/infraguard@0a0f3e2427d883c4d5cbb6f1a4b7ebd78a029b43", + ] in commands_run + assert resolution.decisions["infraguard"].status == "available" + + +def test_prepare_propagates_installer_display_metadata_to_progress_events(tmp_path): + prereqs = _infraguard_prereqs() + go_installer = next( + installer for installer in prereqs["infraguard"]["installers"] if installer["id"] == "go-install" + ) + go_installer["display_key"] = "go_install" + gopath_bin = tmp_path / "bin" + gopath_bin.mkdir() + executable = gopath_bin / "infraguard" + progress_events: list[PrerequisiteProgress] = [] + + def command_exists(command): + return "/usr/local/bin/go" if command == "go" else None + + def run_command(command, env=None): + if command[:2] == ["go", "install"]: + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o755) + if command == ["go", "env", "GOPATH"]: + return CommandResult(command=command, returncode=0, stdout=str(tmp_path), stderr="") + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + resolution = prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="linux", + command_exists=command_exists, + run_command=run_command, + choose_installer=lambda _name, _installers: "go-install", + progress_handler=progress_events.append, + ) + + assert resolution.decisions["infraguard"].status == "available" + assert any(event.installer_display_key == "go_install" for event in progress_events) + + +def test_prepare_finds_existing_go_install_path_hint_before_prompting(tmp_path): + gopath_bin = tmp_path / "bin" + gopath_bin.mkdir() + executable = gopath_bin / "infraguard" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o755) + commands_run = [] + + def command_exists(command): + return "/usr/local/bin/go" if command == "go" else None + + def run_command(command, env=None): + commands_run.append(command) + if command == ["go", "env", "GOPATH"]: + return CommandResult(command=command, returncode=0, stdout=str(tmp_path), stderr="") + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + def choose_installer(_name, _installers): + raise AssertionError("already installed infraguard should be found before prompting") + + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="linux", + command_exists=command_exists, + run_command=run_command, + choose_installer=choose_installer, + ) + + assert commands_run == [["go", "env", "GOBIN"], ["go", "env", "GOPATH"]] + assert resolution.feature_flags == {"enable_reviewing": True} + assert resolution.decisions["infraguard"].status == "available" + assert resolution.decisions["infraguard"].resolved_path == str(executable) + assert resolution.decisions["infraguard"].installer_id == "go-install" + assert resolution.env_overrides["PATH"].split(os.pathsep)[0] == str(gopath_bin) + + +def test_prepare_passes_default_timeout_to_path_hint_commands(tmp_path): + gopath_bin = tmp_path / "bin" + gopath_bin.mkdir() + executable = gopath_bin / "infraguard" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o755) + observed_timeouts: list[tuple[list[str], float | None]] = [] + + def command_exists(command): + return "/usr/local/bin/go" if command == "go" else None + + def run_command(command, env=None, timeout_seconds=None): + observed_timeouts.append((command, timeout_seconds)) + if command == ["go", "env", "GOPATH"]: + return CommandResult(command=command, returncode=0, stdout=str(tmp_path), stderr="") + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="linux", + command_exists=command_exists, + run_command=run_command, + choose_installer=lambda _name, _installers: None, + ) + + assert resolution.decisions["infraguard"].status == "available" + assert observed_timeouts == [ + (["go", "env", "GOBIN"], 30.0), + (["go", "env", "GOPATH"], 30.0), + ] + + +def test_prepare_rejects_outdated_existing_go_install_path_hint(tmp_path): + gopath_bin = tmp_path / "bin" + gopath_bin.mkdir() + executable = gopath_bin / "infraguard" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o755) + offered_installer_ids = [] + + def command_exists(command): + return "/usr/local/bin/go" if command == "go" else None + + def run_command(command, env=None): + if command == ["go", "env", "GOPATH"]: + return CommandResult(command=command, returncode=0, stdout=str(tmp_path), stderr="") + if command == [str(executable), "version"]: + return CommandResult(command=command, returncode=0, stdout="InfraGuard: 0.6.0\n", stderr="") + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + def choose_installer(_name, installers): + offered_installer_ids.extend(installer.id for installer in installers) + return None + + resolution = prepare_prerequisites( + _infraguard_prereqs_with_version_check(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="linux", + command_exists=command_exists, + run_command=run_command, + choose_installer=choose_installer, + ) + + assert offered_installer_ids == ["go-install"] + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "declined_or_unavailable" + assert "0.6.0" in resolution.decisions["infraguard"].message + + +def test_prepare_go_install_prefers_gobin_path_hint_for_post_install(tmp_path): + gobin = tmp_path / "custom-bin" + gobin.mkdir() + executable = gobin / "infraguard" + commands_run = [] + post_install_env_paths = [] + + def command_exists(command): + return "/usr/local/bin/go" if command == "go" else None + + def run_command(command, env=None): + commands_run.append(command) + if command == [ + "go", + "install", + "github.com/aliyun/infraguard/cmd/infraguard@0a0f3e2427d883c4d5cbb6f1a4b7ebd78a029b43", + ]: + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o755) + if command == ["go", "env", "GOBIN"]: + return CommandResult(command=command, returncode=0, stdout=str(gobin), stderr="") + if command == ["go", "env", "GOPATH"]: + return CommandResult(command=command, returncode=0, stdout=str(tmp_path / "gopath"), stderr="") + if command == ["infraguard", "policy", "update"]: + post_install_env_paths.append((env or {}).get("PATH", "")) + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="linux", + command_exists=command_exists, + run_command=run_command, + choose_installer=lambda _name, _installers: "go-install", + ) + + install_index = commands_run.index( + [ + "go", + "install", + "github.com/aliyun/infraguard/cmd/infraguard@0a0f3e2427d883c4d5cbb6f1a4b7ebd78a029b43", + ] + ) + assert ["go", "env", "GOPATH"] not in commands_run[install_index + 1 :] + assert resolution.feature_flags == {"enable_reviewing": True} + assert resolution.decisions["infraguard"].status == "available" + assert resolution.decisions["infraguard"].resolved_path == str(executable) + assert resolution.env_overrides["PATH"].split(os.pathsep)[0] == str(gobin) + assert post_install_env_paths == [resolution.env_overrides["PATH"]] + + +def test_prepare_windows_go_install_path_hints_resolve_exe_and_use_path_for_post_install(tmp_path): + gopath_bin = tmp_path / "bin" + gopath_bin.mkdir() + executable = gopath_bin / "infraguard.exe" + post_install_env_paths = [] + + def command_exists(command): + return "C:/Go/bin/go.exe" if command == "go" else None + + def run_command(command, env=None): + if command == [ + "go", + "install", + "github.com/aliyun/infraguard/cmd/infraguard@0a0f3e2427d883c4d5cbb6f1a4b7ebd78a029b43", + ]: + executable.write_text("@echo off\n", encoding="utf-8") + executable.chmod(0o755) + if command == ["go", "env", "GOPATH"]: + return CommandResult(command=command, returncode=0, stdout=str(tmp_path), stderr="") + if command == ["infraguard", "policy", "update"]: + post_install_env_paths.append((env or {}).get("PATH", "")) + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="windows", + command_exists=command_exists, + run_command=run_command, + choose_installer=lambda _name, _installers: "go-install", + ) + + assert resolution.feature_flags == {"enable_reviewing": True} + assert resolution.decisions["infraguard"].status == "available" + assert resolution.decisions["infraguard"].resolved_path == str(executable) + assert resolution.env_overrides["PATH"].split(os.pathsep)[0] == str(gopath_bin) + assert post_install_env_paths == [resolution.env_overrides["PATH"]] + + +def test_prepare_windows_go_install_prefers_gobin_path_hint_for_post_install(tmp_path): + gobin = tmp_path / "custom-bin" + gobin.mkdir() + executable = gobin / "infraguard.exe" + commands_run = [] + post_install_env_paths = [] + + def command_exists(command): + return "C:/Go/bin/go.exe" if command == "go" else None + + def run_command(command, env=None): + commands_run.append(command) + if command == [ + "go", + "install", + "github.com/aliyun/infraguard/cmd/infraguard@0a0f3e2427d883c4d5cbb6f1a4b7ebd78a029b43", + ]: + executable.write_text("@echo off\n", encoding="utf-8") + executable.chmod(0o755) + if command == ["go", "env", "GOBIN"]: + return CommandResult(command=command, returncode=0, stdout=str(gobin), stderr="") + if command == ["go", "env", "GOPATH"]: + return CommandResult(command=command, returncode=0, stdout=str(tmp_path / "gopath"), stderr="") + if command == ["infraguard", "policy", "update"]: + post_install_env_paths.append((env or {}).get("PATH", "")) + return CommandResult(command=command, returncode=0, stdout="", stderr="") + + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="windows", + command_exists=command_exists, + run_command=run_command, + choose_installer=lambda _name, _installers: "go-install", + ) + + install_index = commands_run.index( + [ + "go", + "install", + "github.com/aliyun/infraguard/cmd/infraguard@0a0f3e2427d883c4d5cbb6f1a4b7ebd78a029b43", + ] + ) + assert ["go", "env", "GOPATH"] not in commands_run[install_index + 1 :] + assert resolution.feature_flags == {"enable_reviewing": True} + assert resolution.decisions["infraguard"].status == "available" + assert resolution.decisions["infraguard"].resolved_path == str(executable) + assert resolution.env_overrides["PATH"].split(os.pathsep)[0] == str(gobin) + assert post_install_env_paths == [resolution.env_overrides["PATH"]] + + +def test_prepare_windows_without_infraguard_and_without_go_offers_no_installer_and_disables_review(): + offered_installer_ids = ["not-called"] + + def choose_installer(_name, installers): + offered_installer_ids[:] = [installer.id for installer in installers] + return None + + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="windows", + command_exists=lambda _command: None, + choose_installer=choose_installer, + ) + + assert offered_installer_ids == [] + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "declined_or_unavailable" + + +def test_selling_pipeline_direct_binary_installer_is_configured_for_declared_platforms(monkeypatch): + for key in list(os.environ): + if key.startswith("IAC_CODE_INFRAGUARD_"): + monkeypatch.delenv(key, raising=False) + + raw = yaml.safe_load(Path("src/iac_code/pipeline/selling/pipeline.yaml").read_text(encoding="utf-8")) + prereqs = {"infraguard": raw["prerequisites"]["infraguard"]} + cases = [ + ("darwin", "arm64"), + ("darwin", "x86_64"), + ("linux", "x86_64"), + ("linux", "aarch64"), + ("windows", "x86_64"), + ("windows", "aarch64"), + ] + + for platform_system, platform_machine in cases: + offered_installer_ids = [] + + def choose_installer(_name, installers): + offered_installer_ids.extend(installer.id for installer in installers) + return None + + prepare_prerequisites( + prereqs, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system=platform_system, + platform_machine=platform_machine, + command_exists=lambda _command: None, + choose_installer=choose_installer, + ) + + assert offered_installer_ids == ["direct-binary"] + + +def test_prepare_declined_installer_disables_review(): + resolution = prepare_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="linux", + command_exists=lambda command: command == "go", + choose_installer=lambda _name, _installers: None, + ) + + assert resolution.feature_flags == {"enable_reviewing": False} + assert resolution.decisions["infraguard"].status == "declined_or_unavailable" + + +def test_to_metadata_returns_plain_dicts_not_dataclass_instances(): + resolution = inspect_prerequisites( + _infraguard_prereqs(), + feature_flags={"enable_reviewing": True}, + command_exists=lambda command: command, + ) + + metadata = resolution.to_metadata() + + assert isinstance(metadata, dict) + assert not is_dataclass(metadata) + assert isinstance(metadata["decisions"]["infraguard"], dict) + assert not is_dataclass(metadata["decisions"]["infraguard"]) diff --git a/tests/pipeline/engine/test_recovery.py b/tests/pipeline/engine/test_recovery.py index a5934146..13122043 100644 --- a/tests/pipeline/engine/test_recovery.py +++ b/tests/pipeline/engine/test_recovery.py @@ -12,6 +12,7 @@ reconstruct_step_result, ) from iac_code.pipeline.engine.types import StepStatus +from iac_code.tools.result_storage import EXTERNALIZED_RESULT_PATH_METADATA_KEY def test_reconstruct_step_result_from_successful_complete_step(): @@ -271,6 +272,130 @@ def test_reconstruct_completion_guard_state_records_ros_deploy_results_for_compl ] +def test_reconstruct_completion_guard_state_records_structured_tool_results_for_completion_guards(): + messages = [ + Message( + role="assistant", + content=[ + ToolUseBlock( + id="tu_scan", + name="infraguard_scan", + input={"file_path": "template.yaml", "blocking_severities": ["high"]}, + ) + ], + ), + Message( + role="user", + content=[ + ToolResultBlock( + tool_use_id="tu_scan", + content=json.dumps( + { + "file_path": "template.yaml", + "passed": True, + "blocking_findings": 0, + "summary": {}, + } + ), + is_error=False, + ) + ], + ), + ] + + state = reconstruct_completion_guard_state(messages) + + assert state["successful_tools"] == {"infraguard_scan"} + assert state["tool_results"]["infraguard_scan"]["passed"] is True + assert state["tool_result_records"] == [ + { + "tool_name": "infraguard_scan", + "input": {"file_path": "template.yaml", "blocking_severities": ["high"]}, + "result": { + "file_path": "template.yaml", + "passed": True, + "blocking_findings": 0, + "summary": {}, + }, + "is_error": False, + } + ] + + +def test_reconstruct_completion_guard_state_reads_externalized_tool_result_metadata(tmp_path): + stored_result_path = tmp_path / "scan.json" + payload = { + "file_path": "template.yaml", + "passed": True, + "blocking_findings": 0, + "file_content": "x" * 60_000, + "file_sha256": "abc123", + } + stored_result_path.write_text(json.dumps(payload), encoding="utf-8") + messages = [ + Message( + role="assistant", + content=[ + ToolUseBlock( + id="tu_scan", + name="infraguard_scan", + input={"file_path": "template.yaml", "include_file_content": True}, + ) + ], + ), + Message( + role="user", + content=[ + ToolResultBlock( + tool_use_id="tu_scan", + content='{"file_path": "template.yaml"', + metadata={EXTERNALIZED_RESULT_PATH_METADATA_KEY: str(stored_result_path)}, + is_error=False, + ) + ], + ), + ] + + state = reconstruct_completion_guard_state(messages) + + assert state["successful_tools"] == {"infraguard_scan"} + assert state["tool_results"]["infraguard_scan"] == payload + + +def test_reconstruct_completion_guard_state_falls_back_when_externalized_tool_result_is_missing(tmp_path, caplog): + caplog.set_level(logging.WARNING, logger="iac_code.pipeline.engine.recovery") + missing_result_path = tmp_path / "missing-scan.json" + messages = [ + Message( + role="assistant", + content=[ + ToolUseBlock( + id="tu_scan", + name="infraguard_scan", + input={"file_path": "template.yaml", "include_file_content": True}, + ) + ], + ), + Message( + role="user", + content=[ + ToolResultBlock( + tool_use_id="tu_scan", + content='{"file_path": "template.yaml"', + metadata={EXTERNALIZED_RESULT_PATH_METADATA_KEY: str(missing_result_path)}, + is_error=False, + ) + ], + ), + ] + + state = reconstruct_completion_guard_state(messages) + + assert "Failed to read externalized tool result while rebuilding completion guard state" in caplog.text + assert state["successful_tools"] == set() + assert state["tool_results"] == {} + + def test_reconstruct_completion_guard_state_records_ros_deploy_owned_failed_create_stack(): messages = [ Message( @@ -322,6 +447,46 @@ def test_completion_guard_state_logs_json_parse_failures(caplog): assert "Failed to parse completion guard state" in caplog.text +def test_completion_guard_state_does_not_warn_for_plain_text_unstructured_tool_results(caplog): + caplog.set_level(logging.WARNING, logger="iac_code.pipeline.engine.completion_guard_state") + state = {} + + record_completion_guard_tool_result( + state, + tool_name="read_file", + tool_input={"path": "/tmp/template.yaml"}, + content="plain template content", + is_error=False, + ) + + assert "Failed to parse completion guard state" not in caplog.text + assert state["successful_tools"] == set() + assert state["tool_results"] == {} + + +def test_completion_guard_state_records_file_mutations_with_plain_text_results(): + state = {} + + record_completion_guard_tool_result( + state, + tool_name="edit_file", + tool_input={"path": "/tmp/template.yaml"}, + content="Successfully edited /tmp/template.yaml", + is_error=False, + ) + + assert state["successful_tools"] == {"edit_file"} + assert state["tool_results"]["edit_file"]["file_path"] == "/tmp/template.yaml" + assert state["tool_result_records"] == [ + { + "tool_name": "edit_file", + "input": {"path": "/tmp/template.yaml"}, + "result": {"file_path": "/tmp/template.yaml"}, + "is_error": False, + } + ] + + def test_completion_guard_state_logs_rebuild_failures(monkeypatch, caplog): caplog.set_level(logging.WARNING, logger="iac_code.pipeline.engine.completion_guard_state") diff --git a/tests/pipeline/engine/test_session.py b/tests/pipeline/engine/test_session.py index e3d3f25e..86acdb66 100644 --- a/tests/pipeline/engine/test_session.py +++ b/tests/pipeline/engine/test_session.py @@ -826,6 +826,11 @@ def test_metadata_unaware_running_save_preserves_existing_dict_metadata(session) execution = {"kind": "step", "step_id": "intent", "active_attempt_id": "att_0001"} attempts = {"next_attempt_number": 2, "items": {"att_0001": {"status": "running"}}} normal_handoff = {"status": "pending", "switched_to_normal": False} + prerequisites = { + "feature_flags": {"enable_reviewing": False}, + "decisions": {"infraguard": {"status": "missing"}}, + "env_overrides": {}, + } session.save_running_sync( "intent", @@ -840,6 +845,7 @@ def test_metadata_unaware_running_save_preserves_existing_dict_metadata(session) execution=execution, attempts=attempts, normal_handoff=normal_handoff, + prerequisites=prerequisites, ) session.save_running_sync( @@ -860,12 +866,14 @@ def test_metadata_unaware_running_save_preserves_existing_dict_metadata(session) assert meta["execution"] == execution assert meta["attempts"] == attempts assert meta["normal_handoff"] == normal_handoff + assert meta["prerequisites"] == prerequisites restored = session.restore_sync(identity) assert restored.ok is True assert restored.execution == execution assert restored.attempts == attempts assert restored.normal_handoff == normal_handoff + assert restored.prerequisites == prerequisites def test_explicit_none_execution_clears_existing_execution_metadata(session): @@ -932,6 +940,11 @@ def test_completed_sidecar_is_terminal_but_preserves_attempts(session): "att_0002": {"scope": "parent", "step_id": "plan", "status": "completed"}, }, } + prerequisites = { + "feature_flags": {"enable_reviewing": False}, + "decisions": {"infraguard": {"status": "missing"}}, + "env_overrides": {}, + } session.save_completed_sync( "plan", @@ -942,6 +955,7 @@ def test_completed_sidecar_is_terminal_but_preserves_attempts(session): execution=execution, attempts=attempts, normal_handoff={"status": "succeeded", "switched_to_normal": True}, + prerequisites=prerequisites, ) meta = yaml.safe_load(session.meta_path.read_text(encoding="utf-8")) @@ -951,6 +965,7 @@ def test_completed_sidecar_is_terminal_but_preserves_attempts(session): assert meta["execution"] == execution assert meta["attempts"] == attempts assert meta["normal_handoff"] == {"status": "succeeded", "switched_to_normal": True} + assert meta["prerequisites"] == prerequisites assert session.has_resumable_status() is False restored = session.restore_sync(identity) @@ -959,6 +974,7 @@ def test_completed_sidecar_is_terminal_but_preserves_attempts(session): assert restored.execution == execution assert restored.attempts == attempts assert restored.normal_handoff == {"status": "succeeded", "switched_to_normal": True} + assert restored.prerequisites == prerequisites def test_metadata_unaware_completed_save_preserves_existing_dict_metadata(session): diff --git a/tests/pipeline/engine/test_step_executor.py b/tests/pipeline/engine/test_step_executor.py index 25cde8d7..5f0b9c61 100644 --- a/tests/pipeline/engine/test_step_executor.py +++ b/tests/pipeline/engine/test_step_executor.py @@ -94,6 +94,26 @@ async def run_streaming(self, user_input): return FakeAgentLoop +class _NamedTool(Tool): + def __init__(self, name: str) -> None: + self._name = name + + @property + def name(self) -> str: + return self._name + + @property + def description(self) -> str: + return self._name + + @property + def input_schema(self) -> dict: + return {"type": "object", "properties": {}} + + async def execute(self, *, tool_input: dict, context: ToolContext) -> ToolResult: + return ToolResult.success("{}") + + class TestStepExecutorToolSetup: def test_complete_step_tool_registered(self, tmp_path): executor = _make_executor(tmp_path) @@ -151,6 +171,37 @@ class DummyTool: assert tool_reg.get("dummy") is not None assert tool_reg.get("complete_step") is not None + def test_tools_include_can_register_pipeline_local_tools(self, tmp_path): + from iac_code.pipeline.engine.loader import load_pipeline_dir + + selling_dir = Path(__file__).resolve().parents[3] / "src" / "iac_code" / "pipeline" / "selling" + loaded = load_pipeline_dir(selling_dir, feature_flag_overrides={"enable_reviewing": True}) + review_step = next( + step for step in loaded.sub_pipelines["evaluate_candidate"].steps if step.step_id == "reviewing" + ) + + registry = ToolRegistry() + registry.register_default_tools() + registry.register(_NamedTool("aliyun_doc_search")) + registry.register(_NamedTool("write_memory")) + executor = StepExecutor( + provider_manager=MagicMock(), + base_tool_registry=registry, + pipeline=loaded, + pipeline_dir=selling_dir, + cwd=str(tmp_path), + ) + + tool_reg = executor._build_step_tools(review_step, PipelineContext({"template": []})) + + assert tool_reg.get("read_file") is not None + assert tool_reg.get("edit_file") is not None + assert tool_reg.get("ros_validate_template") is not None + assert tool_reg.get("aliyun_api") is None + assert tool_reg.get("infraguard_scan") is not None + assert tool_reg.get("write_memory") is None + assert tool_reg.get("bash") is None + class TestStepExecutor: @pytest.mark.asyncio @@ -210,6 +261,194 @@ async def test_detects_rollback_request(self, tmp_path): results = [e for e in collected if isinstance(e, StepResult)] assert results[0].rollback_request == ("spec_recommending", "cost_too_high") + @pytest.mark.asyncio + async def test_completion_guard_records_deep_copy_of_nested_tool_input(self, tmp_path): + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "reviewing.md").write_text("Review.", encoding="utf-8") + step = StepSpec( + step_id="reviewing", + conclusion_field="template", + forward=None, + prompt_file="prompts/reviewing.md", + conclusion_schema={ + "type": "object", + "required": ["file_path", "validated"], + "properties": { + "file_path": {"type": "string"}, + "validated": {"type": "boolean"}, + }, + }, + completion_guards=[ + { + "when_conclusion_field_equals": {"validated": True}, + "require_tool_result": { + "tool": "generic_validator", + "match_conclusion_field": "file_path", + "match_result_field": "input.params.target", + }, + } + ], + ) + pipeline = LoadedPipeline( + name="test", + steps=[step], + context_dependencies={"template": []}, + max_rollbacks=3, + skills={}, + ) + + class FakeAgentLoop: + def __init__(self, **kwargs): + self.tool_registry = kwargs["tool_registry"] + self.calls = 0 + + async def run_streaming(self, user_input): + self.calls += 1 + if self.calls > 1: + return + + validate_input = {"params": {"target": "template.yaml"}} + yield ToolUseStartEvent(tool_use_id="validate_1", name="generic_validator") + yield ToolUseEndEvent(tool_use_id="validate_1", name="generic_validator", input=validate_input) + validate_input["params"].pop("target") + validate_input["params"]["body"] = "ROSTemplateFormatVersion: '2015-09-01'" + yield ToolResultEvent( + tool_use_id="validate_1", + tool_name="generic_validator", + result=json.dumps({"Description": "Valid"}), + ) + + complete_input = {"conclusion": {"file_path": "template.yaml", "validated": True}} + yield ToolUseStartEvent(tool_use_id="done_1", name="complete_step") + yield ToolUseEndEvent(tool_use_id="done_1", name="complete_step", input=complete_input) + complete_tool = self.tool_registry.get("complete_step") + assert complete_tool is not None + complete_result = await complete_tool.execute(tool_input=complete_input, context=ToolContext()) + yield ToolResultEvent( + tool_use_id="done_1", + tool_name="complete_step", + result=complete_result.content, + is_error=complete_result.is_error, + metadata=complete_result.metadata, + ) + + executor = StepExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=tmp_path, + ) + collected = [] + with patch("iac_code.agent.agent_loop.AgentLoop", FakeAgentLoop): + async for event in executor.execute(step, PipelineContext({"template": []}), "session"): + collected.append(event) + + results = [event for event in collected if isinstance(event, StepResult)] + assert len(results) == 1 + assert results[0].status == StepStatus.COMPLETED + + @pytest.mark.asyncio + async def test_completion_guard_reads_externalized_tool_result_metadata(self, tmp_path): + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "reviewing.md").write_text("Review.", encoding="utf-8") + stored_result_path = tmp_path / "externalized-scan.json" + payload = { + "passed": True, + "file_path": "templates/main.yaml", + "file_sha256": "abc123", + "file_content": "x" * 60_000, + } + stored_result = json.dumps(payload) + stored_result_path.write_text(stored_result, encoding="utf-8") + preview = stored_result[:2000] + "\n\n... [truncated - full output saved externally]" + step = StepSpec( + step_id="reviewing", + conclusion_field="template", + forward=None, + prompt_file="prompts/reviewing.md", + conclusion_schema={ + "type": "object", + "required": ["file_path", "review_passed"], + "properties": { + "file_path": {"type": "string"}, + "review_passed": {"type": "boolean"}, + }, + }, + completion_guards=[ + { + "when_conclusion_field_equals": {"review_passed": True}, + "require_tool_result": { + "tool": "infraguard_scan", + "result_field_equals": {"passed": True}, + "required_result_fields": ["file_sha256"], + "match_conclusion_field": "file_path", + "match_result_field": "file_path", + }, + } + ], + ) + pipeline = LoadedPipeline( + name="test", + steps=[step], + context_dependencies={"template": []}, + max_rollbacks=3, + skills={}, + ) + + class FakeAgentLoop: + def __init__(self, **kwargs): + self.tool_registry = kwargs["tool_registry"] + self.calls = 0 + + async def run_streaming(self, user_input): + self.calls += 1 + if self.calls > 1: + return + + scan_input = {"file_path": "templates/main.yaml"} + yield ToolUseStartEvent(tool_use_id="scan_1", name="infraguard_scan") + yield ToolUseEndEvent(tool_use_id="scan_1", name="infraguard_scan", input=scan_input) + yield ToolResultEvent( + tool_use_id="scan_1", + tool_name="infraguard_scan", + result=preview, + metadata={"_iac_code_externalized_result_path": str(stored_result_path)}, + ) + + complete_input = { + "conclusion": { + "file_path": "templates/main.yaml", + "review_passed": True, + } + } + yield ToolUseStartEvent(tool_use_id="done_1", name="complete_step") + yield ToolUseEndEvent(tool_use_id="done_1", name="complete_step", input=complete_input) + complete_tool = self.tool_registry.get("complete_step") + assert complete_tool is not None + complete_result = await complete_tool.execute(tool_input=complete_input, context=ToolContext()) + yield ToolResultEvent( + tool_use_id="done_1", + tool_name="complete_step", + result=complete_result.content, + is_error=complete_result.is_error, + metadata=complete_result.metadata, + ) + + executor = StepExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=tmp_path, + ) + collected = [] + with patch("iac_code.agent.agent_loop.AgentLoop", FakeAgentLoop): + async for event in executor.execute(step, PipelineContext({"template": []}), "session"): + collected.append(event) + + results = [event for event in collected if isinstance(event, StepResult)] + assert len(results) == 1 + assert results[0].status == StepStatus.COMPLETED + @pytest.mark.asyncio async def test_failed_when_no_complete_step(self, tmp_path): events = [TextDeltaEvent(text="Done analyzing.")] diff --git a/tests/pipeline/engine/test_step_executor_integration.py b/tests/pipeline/engine/test_step_executor_integration.py index 8150b1ab..fb08e3bd 100644 --- a/tests/pipeline/engine/test_step_executor_integration.py +++ b/tests/pipeline/engine/test_step_executor_integration.py @@ -130,3 +130,22 @@ def __init__(self, **kwargs): assert agent_context.agent_loop is not None assert "memory_recall_service" not in captured_kwargs + + +def test_step_agent_loop_receives_pipeline_scoped_env_overrides(monkeypatch, tmp_path): + captured_kwargs = {} + + class FakeAgentLoop: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + + monkeypatch.setattr("iac_code.agent.agent_loop.AgentLoop", FakeAgentLoop) + + executor = _make_executor( + tmp_path, + tool_context_env_overrides={"PATH": "/tmp/iac-code-infraguard/bin"}, + ) + agent_context = executor.build_agent_loop_context(_make_step(), PipelineContext({"x": []}), "session-1") + + assert agent_context.agent_loop is not None + assert captured_kwargs["tool_context_env_overrides"] == {"PATH": "/tmp/iac-code-infraguard/bin"} diff --git a/tests/pipeline/engine/test_step_spec.py b/tests/pipeline/engine/test_step_spec.py index a7ea4663..d4d46012 100644 --- a/tests/pipeline/engine/test_step_spec.py +++ b/tests/pipeline/engine/test_step_spec.py @@ -223,3 +223,23 @@ def test_preserves_literal_braces_outside_fields(self): result = render_prompt(template, ctx, ["intent"]) assert '{"literal": true}' in result assert '"k": "v"' in result + + def test_renders_extra_context_with_dotted_refs(self): + ctx = PipelineContext({"intent": []}) + template = "InfraGuard:\n{step_config.infraguard}" + result = render_prompt( + template, + ctx, + [], + extra_context={ + "step_config": { + "infraguard": { + "mode": "static", + "policies": ["rule:aliyun:ecs-instance-no-public-ip"], + } + } + }, + ) + + assert '"mode": "static"' in result + assert '"rule:aliyun:ecs-instance-no-public-ip"' in result diff --git a/tests/pipeline/engine/test_sub_pipeline_executor.py b/tests/pipeline/engine/test_sub_pipeline_executor.py index e83dd4b6..fbd7be9f 100644 --- a/tests/pipeline/engine/test_sub_pipeline_executor.py +++ b/tests/pipeline/engine/test_sub_pipeline_executor.py @@ -63,6 +63,20 @@ def _make_single_step_sub_spec() -> SubPipelineSpec: ) +def _duplicate_template_sub_spec() -> SubPipelineSpec: + return SubPipelineSpec( + name="evaluate_candidate", + max_rollbacks=3, + iterate_over="architecture.candidates", + steps=[ + StepSpec("template_generating", "template", "reviewing", "prompts/template.md"), + StepSpec("reviewing", "template", "cost_estimating", "prompts/review.md"), + StepSpec("cost_estimating", "cost", None, "prompts/cost.md", context_fields=["template"]), + ], + context_fields_from_parent=["intent"], + ) + + class RecordingBackupService: def __init__(self, *, block_on: BackupReason | None = None): self.block_on = block_on @@ -222,6 +236,263 @@ async def execute(self, step, context, session_id, **kwargs): for event in events ) + @pytest.mark.asyncio + async def test_repeated_conclusion_field_overwrites_with_later_step(self, tmp_path, monkeypatch): + """Later duplicate conclusion_field writes should be what downstream steps read.""" + pipeline = LoadedPipeline( + name="test", + steps=[], + context_dependencies={"intent": []}, + max_rollbacks=3, + skills={}, + ) + sub_spec = _duplicate_template_sub_spec() + parent_ctx = PipelineContext({"intent": []}) + parent_ctx.set_conclusion("intent", {"type": "e-commerce"}) + seen_templates = [] + + class FakeStepExecutor: + current_agent_loop = None + + async def execute(self, step, context, session_id, **kwargs): + if step.step_id == "template_generating": + conclusion = {"template": "old", "file_path": "/tmp/t.yaml"} + elif step.step_id == "reviewing": + conclusion = {"template": "reviewed", "file_path": "/tmp/t.yaml"} + else: + seen_templates.append(context.get_conclusion("template")) + conclusion = {"total": 1} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + executor = SubPipelineExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=tmp_path, + ) + monkeypatch.setattr(executor, "_make_step_executor", lambda: FakeStepExecutor()) + + events = [] + async for event in executor.execute_streaming( + sub_spec=sub_spec, + candidate={"name": "Plan A"}, + candidate_index=0, + parent_context=parent_ctx, + session_id="test_session", + ): + events.append(event) + + completed = [ + event + for event in events + if isinstance(event, PipelineEvent) and event.type == PipelineEventType.SUB_PIPELINE_COMPLETED + ][-1] + assert seen_templates == [{"template": "reviewed", "file_path": "/tmp/t.yaml"}] + assert completed.data["conclusions"]["template"] == {"template": "reviewed", "file_path": "/tmp/t.yaml"} + assert completed.data["conclusions"]["cost"] == {"total": 1} + + @pytest.mark.asyncio + async def test_repeated_conclusion_field_rollback_to_second_writer_restores_first_value( + self, tmp_path, monkeypatch + ): + """Rollback to a later duplicate writer must recover the previous writer's value.""" + pipeline = LoadedPipeline( + name="test", + steps=[], + context_dependencies={"intent": []}, + max_rollbacks=3, + skills={}, + ) + sub_spec = _duplicate_template_sub_spec() + parent_ctx = PipelineContext({"intent": []}) + parent_ctx.set_conclusion("intent", {"type": "e-commerce"}) + review_inputs = [] + review_calls = 0 + cost_calls = 0 + + class FakeStepExecutor: + current_agent_loop = None + + async def execute(self, step, context, session_id, **kwargs): + nonlocal cost_calls, review_calls + if step.step_id == "template_generating": + conclusion = {"template": "generated", "file_path": "/tmp/t.yaml"} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + return + if step.step_id == "reviewing": + review_inputs.append(context.get_conclusion("template")) + review_calls += 1 + conclusion = {"template": f"reviewed-{review_calls}", "file_path": "/tmp/t.yaml"} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult( + step_id=step.step_id, + status=StepStatus.COMPLETED, + conclusion=conclusion, + ) + return + cost_calls += 1 + rollback_request = ("reviewing", "review again") if cost_calls == 1 else None + yield StepResult( + step_id=step.step_id, + status=StepStatus.COMPLETED, + conclusion={"total": cost_calls}, + rollback_request=rollback_request, + ) + + executor = SubPipelineExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=tmp_path, + ) + monkeypatch.setattr(executor, "_make_step_executor", lambda: FakeStepExecutor()) + + events = [] + async for event in executor.execute_streaming( + sub_spec=sub_spec, + candidate={"name": "Plan A"}, + candidate_index=0, + parent_context=parent_ctx, + session_id="test_session", + ): + events.append(event) + + completed = [ + event + for event in events + if isinstance(event, PipelineEvent) and event.type == PipelineEventType.SUB_PIPELINE_COMPLETED + ][-1] + assert review_inputs == [ + {"template": "generated", "file_path": "/tmp/t.yaml"}, + {"template": "generated", "file_path": "/tmp/t.yaml"}, + ] + assert completed.data["conclusions"]["template"] == {"template": "reviewed-2", "file_path": "/tmp/t.yaml"} + + @pytest.mark.asyncio + @pytest.mark.parametrize("resume_context_kind", ["empty", "template_only"]) + async def test_restart_with_incomplete_resume_context_hydrates_base_and_preserved_values( + self, tmp_path, monkeypatch, resume_context_kind + ): + """Pending restarts must keep base seeds and replayed values with incomplete resume context.""" + pipeline = LoadedPipeline( + name="test", + steps=[], + context_dependencies={"intent": []}, + max_rollbacks=3, + skills={}, + ) + sub_spec = _duplicate_template_sub_spec() + parent_ctx = PipelineContext({"intent": []}) + parent_ctx.set_conclusion("intent", {"type": "e-commerce"}) + candidate = {"name": "Plan A"} + generated = {"template": "generated", "file_path": "/tmp/t.yaml"} + review_inputs = [] + if resume_context_kind == "template_only": + resume_context = { + "template": { + "value": generated, + "version": 1, + "stale": False, + "updated_at": None, + "history": [], + } + } + else: + resume_context = {} + + class FakeStepExecutor: + current_agent_loop = None + + async def execute(self, step, context, session_id, **kwargs): + if step.step_id == "reviewing": + review_inputs.append( + { + "candidate": context.get_conclusion("candidate"), + "intent": context.get_conclusion("intent"), + "template": context.get_conclusion("template"), + } + ) + conclusion = {"template": "reviewed", "file_path": "/tmp/t.yaml"} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + return + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion={"total": 1}) + + executor = SubPipelineExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=tmp_path, + ) + monkeypatch.setattr(executor, "_make_step_executor", lambda: FakeStepExecutor()) + + events = [] + async for event in executor.execute_streaming( + sub_spec=sub_spec, + candidate=candidate, + candidate_index=0, + parent_context=parent_ctx, + session_id="test_session", + start_from_step="reviewing", + preserved_conclusions={"template": generated}, + preserved_step_conclusions={"template_generating": generated}, + resume_state={ + "sub_pipeline_id": "evaluate_candidate_existing", + "context": resume_context, + "current_sub_step": "reviewing", + "current_index": 1, + }, + ): + events.append(event) + + completed = [ + event + for event in events + if isinstance(event, PipelineEvent) and event.type == PipelineEventType.SUB_PIPELINE_COMPLETED + ][-1] + assert review_inputs == [ + { + "candidate": candidate, + "intent": {"type": "e-commerce"}, + "template": generated, + } + ] + assert completed.data["conclusions"]["template"] == {"template": "reviewed", "file_path": "/tmp/t.yaml"} + + def test_repeated_conclusion_field_marks_template_stale_when_rolled_back_to_reviewing(self, tmp_path): + sub_spec = _duplicate_template_sub_spec() + ctx = PipelineContext({"candidate": [], "template": ["candidate"], "cost": ["template"]}) + ctx.set_conclusion("template", {"template": "reviewed"}) + executor = SubPipelineExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=LoadedPipeline(name="test", steps=[], context_dependencies={}, max_rollbacks=3, skills={}), + pipeline_dir=tmp_path, + ) + + executor._mark_rolled_back_fields_stale(ctx, sub_spec, "reviewing") + + assert ctx.get_field("template").stale is True + + def test_repeated_conclusion_field_drops_template_when_rolled_back_to_first_writer(self, tmp_path): + sub_spec = _duplicate_template_sub_spec() + executor = SubPipelineExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=LoadedPipeline(name="test", steps=[], context_dependencies={}, max_rollbacks=3, skills={}), + pipeline_dir=tmp_path, + ) + + preserved = executor._conclusions_before_step( + sub_spec, + "template_generating", + {"template": {"template": "reviewed"}, "cost": {"total": 1}}, + ) + + assert preserved == {} + @pytest.mark.asyncio async def test_execute_streaming_resumes_current_sub_step_with_attempt_data(self, tmp_path, monkeypatch): """Resume starts at the recorded sub-step and passes attempt/transcript state through.""" diff --git a/tests/pipeline/selling/skills/test_iac_aliyun_review_skill.py b/tests/pipeline/selling/skills/test_iac_aliyun_review_skill.py new file mode 100644 index 00000000..f16c6f2b --- /dev/null +++ b/tests/pipeline/selling/skills/test_iac_aliyun_review_skill.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +SKILL_DIR = ( + Path(__file__).resolve().parents[4] / "src" / "iac_code" / "pipeline" / "selling" / "skills" / "iac-aliyun-review" +) +SKILL_MD = SKILL_DIR / "SKILL.md" +REVIEW_PROMPT_MD = SKILL_DIR.parents[1] / "prompts" / "reviewing.md" + + +def _parse_frontmatter(text: str) -> dict: + assert text.startswith("---"), "SKILL.md must start with YAML frontmatter" + end = text.index("---", 3) + return yaml.safe_load(text[3:end]) + + +def _skill_body() -> str: + content = SKILL_MD.read_text(encoding="utf-8") + end = content.index("---", 3) + 3 + return content[end:] + + +def test_review_skill_frontmatter_declares_template_repair_conclusion() -> None: + fm = _parse_frontmatter(SKILL_MD.read_text(encoding="utf-8")) + + assert fm["name"] == "iac-aliyun-review" + assert fm.get("user_invocable") is False + schema = fm["conclusion_schema"] + assert schema["required"] == [ + "template", + "template_sha256", + "file_path", + "region", + "description", + "validated", + "review_passed", + "review_issues", + "selected_review_aspects", + "skipped_review_aspects", + "resolved_infraguard_policies", + "infraguard_summary", + "fix_summary", + ] + assert schema["properties"]["validated"] == {"const": True} + assert schema["properties"]["review_passed"] == {"const": True} + assert schema["properties"]["template_sha256"] == {"type": "string"} + assert schema["properties"]["selected_review_aspects"] == {"type": "array"} + assert schema["properties"]["skipped_review_aspects"] == {"type": "array"} + assert schema["properties"]["resolved_infraguard_policies"] == {"type": "array"} + assert schema["additionalProperties"] is False + + +def test_review_skill_instructs_direct_infraguard_repair_flow() -> None: + body = _skill_body() + + assert "template.file_path" in body + assert "intent" in body + assert "candidate" in body + assert "aspect" in body + assert "selected_aspects" in body + assert "selected_review_aspects" in body + assert "skipped_review_aspects" in body + assert "read_file" in body + assert "infraguard_scan" in body + assert "step config" in body + assert "write_file" in body or "edit_file" in body + assert "原模板文件" in body + assert "ros_validate_template" in body + assert "ros_validate_template(template_url=template.file_path)" in body + assert "ValidateTemplate" not in body + assert 'aliyun_api(product="ros", action="ValidateTemplate"' not in body + assert "max_fix_rounds" in body + assert "最终" in body and "infraguard_scan" in body + assert "blocking_findings" in body + assert "complete_step" in body + assert "修复依据只能来自 InfraGuard findings、`ros_validate_template` 错误或用户明确约束" in body + assert "硬编码额外的安全、合规、架构规则" in body + assert "0.0.0.0/0" not in body + assert "AllocatePublicIP: true" not in body + assert "include_file_content=true" in body + assert "file_content" in body + assert "file_sha256" in body + assert "complete_step.conclusion.template" in body + assert "初始 `infraguard_scan` 返回 `passed=true`、`blocking_findings=0`" in body + assert "不要调用 `ros_validate_template`,不要再次调用 `infraguard_scan`" in body + assert "只要本步骤修改过 `template.file_path`" in body + + +def test_review_skill_forbids_pipeline_lifecycle_and_placeholder_review_patterns() -> None: + body = _skill_body() + + assert "不要执行 InfraGuard 的 policy update 命令" in body + assert "infraguard policy update" not in body + assert "不要默认使用 rollback_request 回到 `template_generating`" in body + assert "不要把问题只放进单独的 `review` 字段" in body + assert "conclusion_field: review" not in body + assert "target_step 为 `template_generating`" not in body + + +def test_review_skill_records_actionable_scan_errors_without_success_completion() -> None: + body = _skill_body() + prompt = REVIEW_PROMPT_MD.read_text(encoding="utf-8") + + for text in (body, prompt): + assert "command_not_found" in text + assert "timeout" in text + assert "malformed_json" in text + assert "unexpected_exit_code" in text + assert "unknown_policy_aspect" in text + assert "`command`" in text + assert "`stderr`" in text + assert "`selected_aspects`" in text + assert "validated=true" in text + assert "review_passed=true" in text + + +def test_review_skill_does_not_embed_policy_catalog_or_rego_package_bodies() -> None: + assets = "\n".join(path.read_text(encoding="utf-8") for path in SKILL_DIR.rglob("*") if path.is_file()) + + assert "package infraguard.rules" not in assets + assert "package infraguard.packs" not in assets + assert "references/infraguard-policies/" not in assets + assert "generate_infraguard_policies" not in assets + + +def test_review_prompt_repeats_direct_repair_contract() -> None: + prompt = REVIEW_PROMPT_MD.read_text(encoding="utf-8") + + assert "{template.file_path}" in prompt + assert "{intent}" in prompt + assert "{candidate}" in prompt + assert "{step_config.infraguard}" in prompt + assert "selected_aspects" in prompt + assert "aspect_policy_map" in prompt + assert "selected_review_aspects" in prompt + assert "skipped_review_aspects" in prompt + assert "infraguard_scan" in prompt + assert "step config" in prompt + assert "原模板文件" in prompt + assert "ros_validate_template(template_url=template.file_path)" in prompt + assert "ValidateTemplate" not in prompt + assert 'aliyun_api(product="ros", action="ValidateTemplate"' not in prompt + assert "最终" in prompt and "blocking_findings" in prompt + assert "InfraGuard finding" in prompt + assert "硬编码安全/合规/架构规则" in prompt + assert "0.0.0.0/0" not in prompt + assert "直接公网 IP" not in prompt + assert "include_file_content=true" in prompt + assert "file_content" in prompt + assert "file_sha256" in prompt + assert "conclusion.template" in prompt + assert "不要调用 `ros_validate_template`,不要再次调用 `infraguard_scan`" in prompt + assert "只要本步骤修改过 `template.file_path`" in prompt + assert "不要执行 InfraGuard 的 policy update 命令" in prompt + assert "infraguard policy update" not in prompt + assert "不要默认使用 rollback_request 回到 `template_generating`" in prompt + assert "不要把问题只放进单独的 `review` 字段" in prompt + assert "pack:aliyun:" not in prompt + assert "rule:aliyun:" not in prompt diff --git a/tests/pipeline/selling/test_infraguard_scan_tool.py b/tests/pipeline/selling/test_infraguard_scan_tool.py new file mode 100644 index 00000000..b5a2e6cd --- /dev/null +++ b/tests/pipeline/selling/test_infraguard_scan_tool.py @@ -0,0 +1,915 @@ +from __future__ import annotations + +import hashlib +import inspect +import json +import os +import signal +import subprocess +import sys +import time + +import pytest + +from iac_code.pipeline.selling.tools import InfraGuardScanTool +from iac_code.pipeline.selling.tools import infraguard_scan_tool as scan_module +from iac_code.tools.base import ToolContext + + +def _patch_scan_run(monkeypatch, fake_run): + async def run_command(command, *, cwd, timeout_seconds, env=None): + kwargs = { + "capture_output": True, + "text": True, + "check": False, + "cwd": cwd, + "timeout": timeout_seconds, + } + parameters = inspect.signature(fake_run).parameters + accepts_var_kwargs = any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values()) + if "env" in parameters or accepts_var_kwargs: + kwargs["env"] = env + return fake_run(command, **kwargs) + + monkeypatch.setattr(scan_module, "_run_infraguard_command", run_command) + + +class TestInfraGuardScanToolMeta: + def test_name(self): + tool = InfraGuardScanTool() + + assert tool.name == "infraguard_scan" + + def test_input_schema_has_required_file_path(self): + schema = InfraGuardScanTool().input_schema + + assert schema["required"] == ["file_path"] + assert schema["properties"]["mode"]["enum"] == ["static", "preview"] + assert schema["properties"]["blocking_severities"]["default"] == ["high"] + assert schema["properties"]["selected_aspects"]["items"]["type"] == "string" + assert schema["properties"]["aspect_policy_map"]["type"] == "object" + + def test_timeout_leaves_room_for_internal_structured_scan_timeout(self): + assert InfraGuardScanTool().timeout is not None + assert InfraGuardScanTool().timeout > scan_module._SCAN_TIMEOUT_SECONDS + + +@pytest.mark.asyncio +async def test_run_infraguard_command_timeout_terminates_descendant_process(tmp_path): + ticks_path = tmp_path / "ticks.txt" + pid_path = tmp_path / "child.pid" + child_code = ( + "import pathlib, sys, time\n" + "ticks = pathlib.Path(sys.argv[1])\n" + "pathlib.Path(sys.argv[2]).write_text(str(__import__('os').getpid()), encoding='utf-8')\n" + "while True:\n" + " with ticks.open('a', encoding='utf-8') as handle:\n" + " handle.write('x')\n" + " time.sleep(0.05)\n" + ) + parent_code = ( + "import pathlib, subprocess, sys, time\n" + "ticks = pathlib.Path(sys.argv[1])\n" + "subprocess.Popen([sys.executable, '-c', sys.argv[3], sys.argv[1], sys.argv[2]])\n" + "deadline = time.time() + 3\n" + "while not ticks.exists() and time.time() < deadline:\n" + " time.sleep(0.01)\n" + "time.sleep(10)\n" + ) + + child_pid = None + try: + with pytest.raises(subprocess.TimeoutExpired): + await scan_module._run_infraguard_command( + [sys.executable, "-c", parent_code, str(ticks_path), str(pid_path), child_code], + cwd=str(tmp_path), + timeout_seconds=0.5, + ) + + size_after_timeout = ticks_path.stat().st_size + time.sleep(0.3) + assert ticks_path.stat().st_size == size_after_timeout + finally: + if pid_path.exists(): + child_pid = int(pid_path.read_text(encoding="utf-8")) + if child_pid is not None: + _terminate_test_process(child_pid) + + +def _terminate_test_process(pid: int) -> None: + if sys.platform == "win32": + subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True, check=False) + return + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + return + + +class TestInfraGuardScanToolRendering: + def test_compact_result_summarizes_scan_without_raw_json(self): + payload = { + "command": ["infraguard", "scan", "templates/demo.yml", "--format", "json"], + "exit_code": 1, + "mode": "static", + "passed": True, + "blocking_findings": 0, + "findings": [ + { + "severity": "low", + "rule_id": "rule:aliyun:metadata-ros-composer-check", + "resource_id": "", + "line": 5, + } + ], + "summary": { + "total_violations": 1, + "severity_counts": {"high": 0, "low": 1, "medium": 0}, + }, + "file_path": "templates/demo.yml", + "selected_aspects": ["best_practice", "network_architecture"], + "expanded_policies": ["pack:aliyun:best-practice"], + } + + rendered = InfraGuardScanTool().render_tool_result_message(json.dumps(payload), verbose=False) + + assert rendered is not None + assert "passed" in rendered + assert "1 finding" in rendered + assert "blocking 0" in rendered + assert "low 1" in rendered + assert "templates/demo.yml" in rendered + assert '"command"' not in rendered + assert "snippet_lines" not in rendered + + def test_verbose_result_formats_findings_and_policies(self): + payload = { + "command": ["infraguard", "scan", "templates/demo.yml", "--format", "json"], + "exit_code": 1, + "mode": "static", + "ignore_waivers": True, + "passed": True, + "blocking_findings": 0, + "findings": [ + { + "severity": "low", + "rule_id": "rule:aliyun:metadata-ros-composer-check", + "resource_id": "", + "line": 5, + "reason": "Composer 缺失或格式无效。", + "recommendation": "使用 ROS Composer 导入模板并配置架构图。", + } + ], + "summary": { + "total_violations": 1, + "files_scanned": 1, + "severity_counts": {"high": 0, "low": 1, "medium": 0}, + }, + "file_path": "templates/demo.yml", + "selected_aspects": ["best_practice", "network_architecture"], + "expanded_policies": ["pack:aliyun:best-practice", "pack:aliyun:network-architecture"], + } + + rendered = InfraGuardScanTool().render_tool_result_message(json.dumps(payload), verbose=True) + + assert rendered is not None + assert "Status: passed" in rendered + assert "File: templates/demo.yml" in rendered + assert "Aspects: best_practice, network_architecture" in rendered + assert "Policies:" in rendered + assert "pack:aliyun:best-practice" in rendered + assert "Findings:" in rendered + assert "low · rule:aliyun:metadata-ros-composer-check · line 5" in rendered + assert "Reason: Composer 缺失或格式无效。" in rendered + assert '"command"' not in rendered + + def test_error_result_renders_human_readable_error_label(self): + payload = { + "error": "unsupported_no_waivers_flag", + "file_path": "templates/demo.yml", + } + + rendered = InfraGuardScanTool().render_tool_result_message(json.dumps(payload), verbose=False) + + assert rendered is not None + assert "InfraGuard CLI does not support --no-waivers" in rendered + assert "unsupported_no_waivers_flag" not in rendered + + +class TestInfraGuardScanToolExecute: + @pytest.mark.asyncio + async def test_passes_pipeline_scoped_env_to_infraguard_process(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + seen_env = {} + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None, env=None): + seen_env.update(env or {}) + return subprocess.CompletedProcess(command, 0, stdout=json.dumps({"summary": {}, "results": []}), stderr="") + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={"file_path": str(template), "ignore_waivers": False}, + context=ToolContext(env_overrides={"PATH": "/tmp/iac-code-infraguard/bin"}), + ) + + assert result.is_error is False + assert seen_env["PATH"] == "/tmp/iac-code-infraguard/bin" + + @pytest.mark.asyncio + async def test_treats_exit_two_as_scan_result(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("ROSTemplateFormatVersion: '2015-09-01'\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + assert command == [ + "infraguard", + "scan", + str(template), + "--format", + "json", + "--mode", + "static", + "--policy", + "pack:aliyun:security", + "--no-waivers", + ] + assert capture_output is True + assert text is True + assert check is False + assert cwd is not None + assert timeout is not None + return subprocess.CompletedProcess( + command, + 2, + stdout=json.dumps({"summary": {"high": 1}, "results": [{"severity": "high"}]}), + stderr="", + ) + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={ + "file_path": str(template), + "mode": "static", + "policies": ["pack:aliyun:security"], + "blocking_severities": ["high"], + }, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is False + assert payload["exit_code"] == 2 + assert payload["blocking_findings"] == 1 + assert payload["passed"] is False + assert payload["findings"] == [{"severity": "high"}] + assert payload["summary"] == {"high": 1} + assert payload["file_path"] == str(template) + + @pytest.mark.asyncio + async def test_uses_case_insensitive_blocking_severities(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + return subprocess.CompletedProcess( + command, + 1, + stdout=json.dumps({"summary": {"critical": 1}, "results": [{"severity": "CRITICAL"}]}), + stderr="", + ) + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={ + "file_path": str(template), + "ignore_waivers": False, + "blocking_severities": ["critical"], + }, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is False + assert payload["passed"] is False + assert payload["blocking_findings"] == 1 + assert "--no-waivers" not in payload["command"] + + @pytest.mark.asyncio + async def test_fails_when_ignore_waivers_requested_and_cli_does_not_support_flag(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + commands = [] + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + commands.append(command) + if "--no-waivers" in command: + return subprocess.CompletedProcess(command, 1, stdout="", stderr="unknown flag: --no-waivers") + return subprocess.CompletedProcess(command, 0, stdout=json.dumps({"summary": {}, "results": []}), stderr="") + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={"file_path": str(template), "ignore_waivers": True}, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is True + assert len(commands) == 1 + assert "--no-waivers" in commands[0] + assert payload["error"] == "unsupported_no_waivers_flag" + assert payload["command"] == commands[0] + assert payload["ignore_waivers"] is True + + @pytest.mark.asyncio + async def test_fails_when_no_waivers_is_reported_as_unknown_option_with_exit_code_two(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + return subprocess.CompletedProcess(command, 2, stdout="", stderr="unknown option: --no-waivers") + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={"file_path": str(template), "ignore_waivers": True}, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is True + assert payload["error"] == "unsupported_no_waivers_flag" + assert payload["ignore_waivers"] is True + + @pytest.mark.asyncio + async def test_include_file_content_preserves_crlf_for_sha256_contract(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template_content = "ROSTemplateFormatVersion: '2015-09-01'\r\nResources: {}\r\n" + template.write_bytes(template_content.encode("utf-8")) + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + return subprocess.CompletedProcess(command, 0, stdout=json.dumps({"summary": {}, "results": []}), stderr="") + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={ + "file_path": str(template), + "ignore_waivers": False, + "include_file_content": True, + }, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is False + assert payload["file_content"] == template_content + assert payload["file_sha256"] == hashlib.sha256(template_content.encode("utf-8")).hexdigest() + + @pytest.mark.asyncio + async def test_parses_official_top_level_violations_json(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("ROSTemplateFormatVersion: '2015-09-01'\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + return subprocess.CompletedProcess( + command, + 2, + stdout=json.dumps( + { + "summary": {"total": 1, "high": 1, "medium": 0, "low": 0}, + "violations": [ + { + "rule_id": "ecs-no-public-ip", + "severity": "high", + "resource_id": "MyECS", + "reason": "Public IP allocated", + "recommendation": "Use NAT Gateway instead", + } + ], + } + ), + stderr="", + ) + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={"file_path": str(template), "blocking_severities": ["high"]}, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is False + assert payload["passed"] is False + assert payload["blocking_findings"] == 1 + assert payload["findings"] == [ + { + "rule_id": "ecs-no-public-ip", + "severity": "high", + "resource_id": "MyECS", + "reason": "Public IP allocated", + "recommendation": "Use NAT Gateway instead", + } + ] + assert payload["file_sha256"] + + @pytest.mark.asyncio + async def test_summary_severity_counts_are_not_double_counted(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + return subprocess.CompletedProcess( + command, + 2, + stdout=json.dumps( + { + "summary": {"high": 1, "severity_counts": {"high": 1}}, + "violations": [{"severity": "high"}], + } + ), + stderr="", + ) + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={"file_path": str(template), "blocking_severities": ["high"]}, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is False + assert payload["blocking_findings"] == 1 + + @pytest.mark.asyncio + async def test_exit_code_two_with_only_non_blocking_findings_passes(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + return subprocess.CompletedProcess( + command, + 2, + stdout=json.dumps( + { + "summary": {"total_violations": 1, "severity_counts": {"medium": 1}}, + "violations": [{"severity": "medium", "rule_id": "medium-only"}], + } + ), + stderr="", + ) + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={"file_path": str(template), "blocking_severities": ["critical", "high"]}, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is False + assert payload["exit_code"] == 2 + assert payload["passed"] is True + assert payload["blocking_findings"] == 0 + assert payload["findings"] == [{"severity": "medium", "rule_id": "medium-only"}] + + @pytest.mark.asyncio + async def test_expands_selected_aspects_to_policies(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + assert command == [ + "infraguard", + "scan", + str(template), + "--format", + "json", + "--mode", + "static", + "--policy", + "pack:aliyun:security", + "--policy", + "rule:aliyun:ecs-instance-no-public-ip", + "--policy", + "pack:aliyun:high-availability", + "--no-waivers", + ] + return subprocess.CompletedProcess(command, 0, stdout=json.dumps({"summary": {}, "results": []}), stderr="") + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={ + "file_path": str(template), + "selected_aspects": ["security", "high_availability"], + "aspect_policy_map": { + "security": { + "policies": [ + "pack:aliyun:security", + "rule:aliyun:ecs-instance-no-public-ip", + ] + }, + "high_availability": {"policies": ["pack:aliyun:high-availability"]}, + }, + }, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is False + assert payload["selected_aspects"] == ["security", "high_availability"] + assert payload["expanded_policies"] == [ + "pack:aliyun:security", + "rule:aliyun:ecs-instance-no-public-ip", + "pack:aliyun:high-availability", + ] + + @pytest.mark.asyncio + async def test_rejects_raw_policies_when_aspect_policy_map_is_present(self, tmp_path): + result = await InfraGuardScanTool().execute( + tool_input={ + "file_path": str(tmp_path / "template.yaml"), + "policies": ["rule:*"], + "selected_aspects": ["security"], + "aspect_policy_map": {"security": {"policies": ["pack:aliyun:security"]}}, + }, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is True + assert payload["error"] == "raw_policies_not_allowed_with_aspects" + + @pytest.mark.asyncio + async def test_requires_selected_aspects_when_aspect_policy_map_is_present(self, tmp_path): + result = await InfraGuardScanTool().execute( + tool_input={ + "file_path": str(tmp_path / "template.yaml"), + "selected_aspects": [], + "aspect_policy_map": {"security": {"policies": ["pack:aliyun:security"]}}, + }, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is True + assert payload["error"] == "selected_aspects_required" + + @pytest.mark.asyncio + async def test_step_config_aspects_are_used_without_caller_supplied_map(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + assert command == [ + "infraguard", + "scan", + str(template), + "--format", + "json", + "--mode", + "static", + "--policy", + "pack:aliyun:security", + "--no-waivers", + ] + return subprocess.CompletedProcess(command, 0, stdout=json.dumps({"summary": {}, "results": []}), stderr="") + + _patch_scan_run(monkeypatch, fake_run) + tool = InfraGuardScanTool( + step_config={ + "infraguard": { + "mode": "static", + "ignore_waivers": True, + "aspects": {"security": {"policies": ["pack:aliyun:security"]}}, + } + } + ) + + result = await tool.execute( + tool_input={"file_path": str(template), "selected_aspects": ["security"]}, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is False + assert payload["selected_aspects"] == ["security"] + assert payload["expanded_policies"] == ["pack:aliyun:security"] + + @pytest.mark.asyncio + async def test_step_config_scan_options_override_caller_input(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + assert command == [ + "infraguard", + "scan", + str(template), + "--format", + "json", + "--mode", + "static", + "--policy", + "pack:aliyun:security", + "--no-waivers", + ] + return subprocess.CompletedProcess( + command, + 2, + stdout=json.dumps({"summary": {"severity_counts": {"critical": 1}}, "results": []}), + stderr="", + ) + + _patch_scan_run(monkeypatch, fake_run) + tool = InfraGuardScanTool( + step_config={ + "infraguard": { + "mode": "static", + "ignore_waivers": True, + "blocking_severities": ["critical", "high"], + "aspects": {"security": {"policies": ["pack:aliyun:security"]}}, + } + } + ) + + result = await tool.execute( + tool_input={ + "file_path": str(template), + "mode": "preview", + "selected_aspects": ["security"], + "ignore_waivers": False, + "blocking_severities": ["high"], + }, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is False + assert payload["mode"] == "static" + assert payload["ignore_waivers"] is True + assert payload["blocking_severities"] == ["critical", "high"] + assert payload["passed"] is False + assert payload["blocking_findings"] == 1 + + @pytest.mark.asyncio + async def test_step_config_aspect_mode_rejects_raw_policies_without_caller_map(self, tmp_path): + tool = InfraGuardScanTool( + step_config={"infraguard": {"aspects": {"security": {"policies": ["pack:aliyun:security"]}}}} + ) + + result = await tool.execute( + tool_input={ + "file_path": str(tmp_path / "template.yaml"), + "policies": ["rule:*"], + "selected_aspects": ["security"], + }, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is True + assert payload["error"] == "raw_policies_not_allowed_with_aspects" + + @pytest.mark.asyncio + async def test_unknown_selected_aspect_is_tool_error(self, tmp_path): + result = await InfraGuardScanTool().execute( + tool_input={ + "file_path": str(tmp_path / "template.yaml"), + "selected_aspects": ["security", "unknown"], + "aspect_policy_map": {"security": {"policies": ["pack:aliyun:security"]}}, + }, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is True + assert payload["error"] == "unknown_policy_aspect" + assert payload["unknown_aspects"] == ["unknown"] + + @pytest.mark.asyncio + async def test_flattens_infraguard_v010_nested_violations(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + return subprocess.CompletedProcess( + command, + 2, + stdout=json.dumps( + { + "schema_version": "2.0", + "summary": {"severity_counts": {"high": 1}}, + "results": [ + { + "file": str(template), + "violations": [ + { + "id": "rule:aliyun:ecs-instance-no-public-ip", + "severity": "high", + "resource_id": "EcsGroup", + "violation_path": ["Properties", "AllocatePublicIP"], + } + ], + } + ], + } + ), + stderr="", + ) + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={"file_path": str(template), "blocking_severities": ["high"]}, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is False + assert payload["passed"] is False + assert payload["blocking_findings"] == 1 + assert payload["findings"] == [ + { + "id": "rule:aliyun:ecs-instance-no-public-ip", + "severity": "high", + "resource_id": "EcsGroup", + "violation_path": ["Properties", "AllocatePublicIP"], + "file": str(template), + } + ] + + @pytest.mark.asyncio + async def test_ignores_infraguard_v010_empty_violation_groups(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + return subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps( + { + "schema_version": "2.0", + "summary": {"severity_counts": {"high": 0}}, + "results": [{"file": str(template), "violations": None}], + } + ), + stderr="", + ) + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={"file_path": str(template), "blocking_severities": ["high"]}, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is False + assert payload["passed"] is True + assert payload["blocking_findings"] == 0 + assert payload["findings"] == [] + + @pytest.mark.asyncio + async def test_malformed_json_is_tool_error(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + return subprocess.CompletedProcess(command, 0, stdout="{not-json", stderr="bad json") + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={"file_path": str(template)}, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is True + assert payload["error"] == "malformed_json" + assert payload["exit_code"] == 0 + + @pytest.mark.asyncio + async def test_top_level_error_payload_is_tool_error(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + return subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps({"error": "policy_not_found", "message": "missing policy"}), + stderr="policy stderr", + ) + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={ + "file_path": str(template), + "selected_aspects": ["security"], + "aspect_policy_map": {"security": {"policies": ["pack:aliyun:security"]}}, + }, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is True + assert payload["error"] == "policy_not_found" + assert payload["exit_code"] == 0 + assert payload["stderr"] == "policy stderr" + assert payload["selected_aspects"] == ["security"] + assert payload["expanded_policies"] == ["pack:aliyun:security"] + + @pytest.mark.asyncio + async def test_unexpected_exit_code_is_tool_error(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + return subprocess.CompletedProcess(command, 127, stdout="", stderr="missing binary") + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={"file_path": str(template)}, + context=ToolContext(), + ) + + payload = json.loads(result.content) + assert result.is_error is True + assert payload["error"] == "unexpected_exit_code" + assert payload["exit_code"] == 127 + assert payload["stderr"] == "missing binary" + + @pytest.mark.asyncio + async def test_relative_file_path_uses_context_cwd_and_preserves_payload_path(self, monkeypatch, tmp_path): + template = tmp_path / "template.yaml" + template.write_text("{}\n", encoding="utf-8") + + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + assert command[:3] == ["infraguard", "scan", "template.yaml"] + assert cwd == str(tmp_path) + assert timeout is not None + return subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps({"summary": {}, "results": []}), + stderr="", + ) + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={"file_path": "template.yaml"}, + context=ToolContext(cwd=str(tmp_path)), + ) + + payload = json.loads(result.content) + assert result.is_error is False + assert payload["file_path"] == "template.yaml" + + @pytest.mark.asyncio + async def test_command_not_found_is_tool_error(self, monkeypatch, tmp_path): + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + raise FileNotFoundError("infraguard") + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={"file_path": "template.yaml"}, + context=ToolContext(cwd=str(tmp_path)), + ) + + payload = json.loads(result.content) + assert result.is_error is True + assert payload["error"] == "command_not_found" + assert payload["file_path"] == "template.yaml" + assert "infraguard" in payload["stderr"] + + @pytest.mark.asyncio + async def test_timeout_is_tool_error(self, monkeypatch, tmp_path): + def fake_run(command, capture_output, text, check, cwd=None, timeout=None): + raise subprocess.TimeoutExpired(command, timeout) + + _patch_scan_run(monkeypatch, fake_run) + + result = await InfraGuardScanTool().execute( + tool_input={"file_path": "template.yaml"}, + context=ToolContext(cwd=str(tmp_path)), + ) + + payload = json.loads(result.content) + assert result.is_error is True + assert payload["error"] == "timeout" + assert payload["file_path"] == "template.yaml" + assert payload["timeout"] is not None diff --git a/tests/pipeline/selling/test_memory_policy.py b/tests/pipeline/selling/test_memory_policy.py index a7105015..e2abdd20 100644 --- a/tests/pipeline/selling/test_memory_policy.py +++ b/tests/pipeline/selling/test_memory_policy.py @@ -42,9 +42,17 @@ def test_pipeline_steps_do_not_offer_write_memory_by_default() -> None: assert "write_memory" in deploying.tools.exclude -def test_reviewing_step_exposes_template_validation_tool_when_enabled(monkeypatch) -> None: +def test_reviewing_step_exposes_infraguard_review_tools_when_enabled(monkeypatch) -> None: monkeypatch.setenv("IAC_CODE_PIPELINE_SELLING_ENABLE_REVIEWING", "true") reviewing = _sub_step_by_id("evaluate_candidate", "reviewing") assert reviewing.tools is not None - assert "ros_validate_template" in reviewing.tools.include + assert { + "read_file", + "write_file", + "edit_file", + "infraguard_scan", + "ros_validate_template", + "aliyun_doc_search", + }.issubset(reviewing.tools.include) + assert "aliyun_api" not in reviewing.tools.include diff --git a/tests/pipeline/selling/test_pipeline_reviewing.py b/tests/pipeline/selling/test_pipeline_reviewing.py new file mode 100644 index 00000000..9d7a1621 --- /dev/null +++ b/tests/pipeline/selling/test_pipeline_reviewing.py @@ -0,0 +1,477 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import yaml + +from iac_code.pipeline.engine.loader import load_pipeline_dir +from iac_code.pipeline.engine.step_spec import A2AArtifactSpec, render_prompt + + +def _selling_dir() -> Path: + return Path(__file__).resolve().parents[3] / "src" / "iac_code" / "pipeline" / "selling" + + +def _load_selling(*, enable_reviewing: bool | None = None): + overrides = None if enable_reviewing is None else {"enable_reviewing": enable_reviewing} + return load_pipeline_dir(_selling_dir(), feature_flag_overrides=overrides) + + +def test_review_enabled_loads_infraguard_repair_step_before_cost() -> None: + loaded = _load_selling(enable_reviewing=True) + + steps = loaded.sub_pipelines["evaluate_candidate"].steps + assert [step.step_id for step in steps] == ["template_generating", "reviewing", "cost_estimating"] + + template_step, review_step, cost_step = steps + assert review_step.enabled_when == "enable_reviewing" + assert review_step.conclusion_field == "template" + assert review_step.forward == "cost_estimating" + assert review_step.skill == "iac-aliyun-review" + assert review_step.prompt_file == "prompts/reviewing.md" + assert review_step.context_fields == ["intent", "candidate", "template"] + assert cost_step.context_fields == ["template"] + + assert review_step.tools is not None + assert review_step.tools.include == [ + "read_file", + "write_file", + "edit_file", + "infraguard_scan", + "ros_validate_template", + "aliyun_doc_search", + ] + assert review_step.tools.exclude == ["write_memory"] + assert review_step.inject_tools == ["ros_validate_template"] + + infraguard_config = review_step.config["infraguard"] + assert infraguard_config["mode"] == "static" + assert infraguard_config["ignore_waivers"] is True + assert infraguard_config["max_fix_rounds"] == 5 + assert infraguard_config["blocking_severities"] == ["critical", "high"] + assert list(infraguard_config["aspects"]) == [ + "security", + "high_availability", + "cost_optimization", + "compliance", + "best_practice", + "operations", + "network_architecture", + "elasticity", + ] + assert {key: value["policies"] for key, value in infraguard_config["aspects"].items()} == { + "security": ["pack:aliyun:security"], + "high_availability": ["pack:aliyun:high-availability"], + "cost_optimization": ["pack:aliyun:cost-optimization"], + "compliance": ["pack:aliyun:compliance"], + "best_practice": ["pack:aliyun:best-practice"], + "operations": ["pack:aliyun:operations"], + "network_architecture": ["pack:aliyun:network-architecture"], + "elasticity": ["pack:aliyun:elasticity"], + } + assert template_step.a2a_artifacts == [ + A2AArtifactSpec( + path="conclusion.file_path", + content="conclusion.template", + media_type="auto", + role="intermediate", + supersedes_path="conclusion.file_path", + ) + ] + assert review_step.a2a_artifacts == [ + A2AArtifactSpec( + path="conclusion.file_path", + content="conclusion.template", + media_type="auto", + role="final", + supersedes_path="conclusion.file_path", + ) + ] + + +def test_review_step_schema_and_completion_guards_allow_clean_scan_shortcut() -> None: + loaded = _load_selling(enable_reviewing=True) + review_step = loaded.sub_pipelines["evaluate_candidate"].steps[1] + + assert review_step.conclusion_schema == { + "type": "object", + "required": [ + "template", + "template_sha256", + "file_path", + "region", + "description", + "validated", + "review_passed", + "review_issues", + "selected_review_aspects", + "skipped_review_aspects", + "resolved_infraguard_policies", + "infraguard_summary", + "fix_summary", + ], + "additionalProperties": False, + "properties": { + "template": {"type": "string"}, + "template_sha256": {"type": "string"}, + "file_path": {"type": "string"}, + "region": {"type": "string"}, + "description": {"type": "string"}, + "validated": {"const": True}, + "review_passed": {"const": True}, + "review_issues": {"type": "array"}, + "selected_review_aspects": {"type": "array"}, + "skipped_review_aspects": {"type": "array"}, + "resolved_infraguard_policies": {"type": "array"}, + "infraguard_summary": {"type": "object"}, + "fix_summary": {"type": "string"}, + }, + } + assert review_step.completion_guards == [ + { + "when_conclusion_field_equals": {"validated": True}, + "when_tool_result_exists": { + "tools": ["write_file", "edit_file"], + "match_conclusion_field": "file_path", + "match_result_field": "result.file_path", + }, + "require_tool_result": { + "tool": "ros_validate_template", + "match_conclusion_field": "file_path", + "match_result_field": "input.template_url", + "disallow_tool_results_after_match": [ + { + "tools": ["write_file", "edit_file"], + "match_conclusion_field": "file_path", + "match_result_field": "result.file_path", + "message_key": "reviewing_rerun_after_validate_template_write", + } + ], + }, + "message_key": "reviewing_validate_template_required", + }, + { + "when_conclusion_field_equals": {"review_passed": True}, + "require_tool_result": { + "tool": "infraguard_scan", + "latest_match": True, + "match_conclusion_field": "file_path", + "match_result_field": "file_path", + "match_fields": [{"conclusion_field": "template_sha256", "result_field": "file_sha256"}], + "result_field_equals": {"passed": True, "blocking_findings": 0}, + "required_result_fields": ["file_content", "file_sha256", "selected_aspects", "expanded_policies"], + "disallow_tool_results_after_match": [ + { + "tools": ["write_file", "edit_file"], + "match_conclusion_field": "file_path", + "match_result_field": "result.file_path", + "message_key": "reviewing_rerun_after_final_infraguard_write", + } + ], + }, + "require_conclusion_sha256": { + "content_field": "template", + "sha256_field": "template_sha256", + }, + "message_key": "reviewing_final_infraguard_required", + }, + { + "when_conclusion_field_equals": {"review_passed": True}, + "when_tool_result_exists": { + "tools": ["write_file", "edit_file"], + "match_conclusion_field": "file_path", + "match_result_field": "result.file_path", + }, + "require_tool_result": { + "tool": "infraguard_scan", + "latest_match": True, + "after_tool_result": { + "tool": "ros_validate_template", + "match_conclusion_field": "file_path", + "match_result_field": "input.template_url", + }, + "match_conclusion_field": "file_path", + "match_result_field": "file_path", + "match_fields": [{"conclusion_field": "template_sha256", "result_field": "file_sha256"}], + "result_field_equals": {"passed": True, "blocking_findings": 0}, + "required_result_fields": ["file_content", "file_sha256", "selected_aspects", "expanded_policies"], + "disallow_tool_results_after_match": [ + { + "tools": ["write_file", "edit_file"], + "match_conclusion_field": "file_path", + "match_result_field": "result.file_path", + "message_key": "reviewing_rerun_after_final_infraguard_write", + } + ], + }, + "message_key": "reviewing_final_infraguard_required", + }, + ] + + +def test_review_disabled_removes_review_step_and_generated_template_remains_final() -> None: + loaded = _load_selling(enable_reviewing=False) + + steps = loaded.sub_pipelines["evaluate_candidate"].steps + assert [step.step_id for step in steps] == ["template_generating", "cost_estimating"] + + template_step, cost_step = steps + assert template_step.forward == "cost_estimating" + assert template_step.a2a_artifacts == [ + A2AArtifactSpec( + path="conclusion.file_path", + content="conclusion.template", + media_type="auto", + role="final", + supersedes_path="conclusion.file_path", + ) + ] + assert cost_step.context_fields == ["template"] + + +def test_selling_completion_guards_use_message_keys_not_raw_messages() -> None: + raw = yaml.safe_load((_selling_dir() / "pipeline.yaml").read_text(encoding="utf-8")) + + def iter_guards() -> list[dict]: + guards: list[dict] = [] + for step in raw.get("steps") or []: + if isinstance(step, dict): + guards.extend(guard for guard in step.get("completion_guards") or [] if isinstance(guard, dict)) + for sub_pipeline in (raw.get("sub_pipelines") or {}).values(): + if not isinstance(sub_pipeline, dict): + continue + for step in sub_pipeline.get("steps") or []: + if isinstance(step, dict): + guards.extend(guard for guard in step.get("completion_guards") or [] if isinstance(guard, dict)) + return guards + + def assert_no_raw_message(config: dict) -> None: + assert "message" not in config + for value in config.values(): + if isinstance(value, dict): + assert_no_raw_message(value) + elif isinstance(value, list): + for item in value: + if isinstance(item, dict): + assert_no_raw_message(item) + + guards = iter_guards() + assert guards + for guard in guards: + assert_no_raw_message(guard) + + +def test_review_prompt_renders_infraguard_config_from_pipeline_yaml() -> None: + from iac_code.pipeline.engine.context import PipelineContext + + loaded = _load_selling(enable_reviewing=True) + review_step = loaded.sub_pipelines["evaluate_candidate"].steps[1] + prompt = (_selling_dir() / review_step.prompt_file).read_text(encoding="utf-8") + ctx = PipelineContext({"intent": [], "candidate": [], "template": []}) + ctx.set_conclusion("intent", {"business_type": "demo", "non_functional": {"availability": "low_cost"}}) + ctx.set_conclusion("candidate", {"name": "低成本单机方案", "topology": "single-zone ECS"}) + ctx.set_conclusion( + "template", + { + "file_path": "/tmp/template.yaml", + "region": "cn-hangzhou", + "description": "demo", + }, + ) + + rendered = render_prompt( + prompt, + ctx, + review_step.context_fields, + extra_context={"step_config": review_step.config}, + ) + + assert "{step_config" not in rendered + assert '"security"' in rendered + assert '"high_availability"' in rendered + assert '"cost_optimization"' in rendered + assert '"pack:aliyun:security"' in rendered + assert '"pack:aliyun:network-architecture"' in rendered + assert "低成本单机方案" in rendered + assert "single-zone ECS" in rendered + assert "selected_aspects" in rendered + + +def test_step_executor_injects_review_step_config_into_prompt(tmp_path) -> None: + from unittest.mock import MagicMock + + from iac_code.pipeline.engine.context import PipelineContext + from iac_code.pipeline.engine.step_executor import StepExecutor + from iac_code.tools.base import ToolRegistry + + loaded = _load_selling(enable_reviewing=True) + review_step = loaded.sub_pipelines["evaluate_candidate"].steps[1] + ctx = PipelineContext({"intent": [], "candidate": [], "template": []}) + ctx.set_conclusion("intent", {"business_type": "demo"}) + ctx.set_conclusion("candidate", {"name": "demo plan"}) + ctx.set_conclusion( + "template", + { + "file_path": "/tmp/template.yaml", + "region": "cn-hangzhou", + "description": "demo", + }, + ) + + prompt = StepExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=loaded, + pipeline_dir=_selling_dir(), + cwd=str(tmp_path), + )._build_full_system_prompt(review_step, ctx) + + assert "{step_config" not in prompt + assert '"security"' in prompt + assert '"high_availability"' in prompt + assert '"pack:aliyun:security"' in prompt + assert '"pack:aliyun:network-architecture"' in prompt + assert "demo plan" in prompt + + +def test_selling_declares_infraguard_prerequisite_installers_and_policy_update() -> None: + loaded = _load_selling() + + assert loaded.prerequisites["infraguard"] == { + "command": "infraguard", + "required_by_flags": ["enable_reviewing"], + "on_missing": {"repl": "prompt_install", "non_interactive": "disable_feature"}, + "version_check": { + "command": ["infraguard", "version"], + "minimum": "0.10.1", + "pattern": r"InfraGuard:\s*(?P\d+\.\d+\.\d+)", + "timeout_seconds": 30, + }, + "installers": [ + { + "id": "direct-binary", + "display_key": "direct_binary_download", + "platforms": ["darwin", "linux", "windows"], + "download": { + "install_dir": "~/bin", + "installed_name": "infraguard", + "timeout_seconds": 1800, + "assets": [ + { + "platforms": ["darwin"], + "architectures": ["arm64"], + "filename": "infraguard-v0.10.1-darwin-arm64", + "sha256": "cda2ba2eab1076a5f8b6c66654295a9b9aa8e9b302407e9580a4d75b7872b76f", + "urls": [ + {"env": "IAC_CODE_INFRAGUARD_DARWIN_ARM64_URL"}, + "https://ros-public-tools.oss-cn-beijing.aliyuncs.com/github-releases/aliyun/infraguard/0.10.1/infraguard-v0.10.1-darwin-arm64", + "https://github.com/aliyun/infraguard/releases/download/v0.10.1/infraguard-v0.10.1-darwin-arm64", + ], + }, + { + "platforms": ["darwin"], + "architectures": ["amd64"], + "filename": "infraguard-v0.10.1-darwin-amd64", + "sha256": "d9e8963250de8a13bbe4a6e9e528ad638f6b8fb4ad6928e2bfc27771a1db3260", + "urls": [ + {"env": "IAC_CODE_INFRAGUARD_DARWIN_AMD64_URL"}, + "https://ros-public-tools.oss-cn-beijing.aliyuncs.com/github-releases/aliyun/infraguard/0.10.1/infraguard-v0.10.1-darwin-amd64", + "https://github.com/aliyun/infraguard/releases/download/v0.10.1/infraguard-v0.10.1-darwin-amd64", + ], + }, + { + "platforms": ["linux"], + "architectures": ["amd64"], + "filename": "infraguard-v0.10.1-linux-amd64", + "sha256": "a0f66d5390df1746b10bdd0e87dfe68b6418c22d43add22a4ca9cc99f22ef66b", + "urls": [ + {"env": "IAC_CODE_INFRAGUARD_LINUX_AMD64_URL"}, + "https://ros-public-tools.oss-cn-beijing.aliyuncs.com/github-releases/aliyun/infraguard/0.10.1/infraguard-v0.10.1-linux-amd64", + "https://github.com/aliyun/infraguard/releases/download/v0.10.1/infraguard-v0.10.1-linux-amd64", + ], + }, + { + "platforms": ["linux"], + "architectures": ["arm64"], + "filename": "infraguard-v0.10.1-linux-arm64", + "sha256": "5d929b89ff6ef5e8d6cd95ce3d84cb1747452f055706037b972463126065e262", + "urls": [ + {"env": "IAC_CODE_INFRAGUARD_LINUX_ARM64_URL"}, + "https://ros-public-tools.oss-cn-beijing.aliyuncs.com/github-releases/aliyun/infraguard/0.10.1/infraguard-v0.10.1-linux-arm64", + "https://github.com/aliyun/infraguard/releases/download/v0.10.1/infraguard-v0.10.1-linux-arm64", + ], + }, + { + "platforms": ["windows"], + "architectures": ["amd64"], + "filename": "infraguard-v0.10.1-windows-amd64.exe", + "sha256": "48dd98cec9cc825fd273280e3ca8502fc65ab31170394b18d6e388ccb4c80332", + "urls": [ + {"env": "IAC_CODE_INFRAGUARD_WINDOWS_AMD64_URL"}, + "https://ros-public-tools.oss-cn-beijing.aliyuncs.com/github-releases/aliyun/infraguard/0.10.1/infraguard-v0.10.1-windows-amd64.exe", + "https://github.com/aliyun/infraguard/releases/download/v0.10.1/infraguard-v0.10.1-windows-amd64.exe", + ], + }, + { + "platforms": ["windows"], + "architectures": ["arm64"], + "filename": "infraguard-v0.10.1-windows-arm64.exe", + "sha256": "7ab067e0af3e59173bf04d8a2624a2cf6b42eda0fe578bc0be2540394d49636e", + "urls": [ + {"env": "IAC_CODE_INFRAGUARD_WINDOWS_ARM64_URL"}, + "https://ros-public-tools.oss-cn-beijing.aliyuncs.com/github-releases/aliyun/infraguard/0.10.1/infraguard-v0.10.1-windows-arm64.exe", + "https://github.com/aliyun/infraguard/releases/download/v0.10.1/infraguard-v0.10.1-windows-arm64.exe", + ], + }, + ], + }, + }, + ], + "post_install": {"timeout_seconds": 300, "commands": [["infraguard", "policy", "update"]]}, + } + + +def test_selling_direct_binary_assets_all_declare_sha256() -> None: + loaded = _load_selling() + installers = loaded.prerequisites["infraguard"]["installers"] + direct_binary = next(installer for installer in installers if installer["id"] == "direct-binary") + + assets = direct_binary["download"]["assets"] + + assert assets + assert all(asset.get("sha256") for asset in assets) + + +def test_selling_only_offers_direct_binary_infraguard_installer() -> None: + loaded = _load_selling() + installers = loaded.prerequisites["infraguard"]["installers"] + + assert [installer["id"] for installer in installers] == ["direct-binary"] + + +def test_selling_public_direct_binary_assets_do_not_require_env_url(monkeypatch) -> None: + from iac_code.pipeline.engine.prerequisites import prepare_prerequisites + + loaded = _load_selling() + offered_installer_ids = [] + + for key in tuple(os.environ): + if key.startswith("IAC_CODE_INFRAGUARD_"): + monkeypatch.delenv(key, raising=False) + + def choose_installer(_name, installers): + offered_installer_ids.extend(installer.id for installer in installers) + return None + + prepare_prerequisites( + loaded.prerequisites, + feature_flags={"enable_reviewing": True}, + surface="repl", + platform_system="linux", + platform_machine="amd64", + command_exists=lambda _command: None, + choose_installer=choose_installer, + ) + + assert offered_installer_ids == ["direct-binary"] diff --git a/tests/pipeline/test_discovery.py b/tests/pipeline/test_discovery.py index ecf68a99..ff8b9a1e 100644 --- a/tests/pipeline/test_discovery.py +++ b/tests/pipeline/test_discovery.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock import pytest +import yaml from iac_code.pipeline import create_pipeline, discover_pipelines @@ -39,3 +40,66 @@ def test_unknown_pipeline_raises(self): session_storage=MagicMock(), session_id="test", ) + + def test_passes_prerequisite_resolution_to_runner(self, monkeypatch, tmp_path): + import iac_code.pipeline.engine.pipeline_runner as runner_module + + pipeline_dir = tmp_path / "pipeline" + pipeline_dir.mkdir() + runner = MagicMock() + runner_cls = MagicMock(return_value=runner) + prerequisite_resolution = { + "feature_flags": {"enable_reviewing": False}, + "decisions": {"infraguard": {"status": "missing"}}, + "env_overrides": {}, + } + + monkeypatch.setattr("iac_code.pipeline.discover_pipelines", lambda: {"selling": pipeline_dir}) + monkeypatch.setattr(runner_module, "PipelineRunner", runner_cls) + + result = create_pipeline( + "selling", + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=MagicMock(), + session_id="test123", + prerequisite_resolution=prerequisite_resolution, + ) + + assert result is runner + assert runner_cls.call_args.kwargs["prerequisite_resolution"] is prerequisite_resolution + + def test_resume_from_sidecar_peeks_prerequisites_before_runner_creation(self, monkeypatch, tmp_path): + import iac_code.pipeline.engine.pipeline_runner as runner_module + + pipeline_dir = tmp_path / "pipeline" + pipeline_dir.mkdir() + storage = MagicMock() + storage.session_dir.side_effect = lambda cwd, sid: tmp_path / "sessions" / sid + sidecar_dir = storage.session_dir("/workspace", "sess123") / "pipeline" + sidecar_dir.mkdir(parents=True) + prerequisites = { + "feature_flags": {"enable_reviewing": False}, + "decisions": {"infraguard": {"status": "missing"}}, + "env_overrides": {"PATH": "/tmp/tools"}, + } + (sidecar_dir / "meta.yaml").write_text( + yaml.dump({"status": "running", "prerequisites": prerequisites}), + encoding="utf-8", + ) + runner_cls = MagicMock(return_value=MagicMock()) + + monkeypatch.setattr("iac_code.pipeline.discover_pipelines", lambda: {"selling": pipeline_dir}) + monkeypatch.setattr(runner_module, "PipelineRunner", runner_cls) + + create_pipeline( + "selling", + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=storage, + session_id="sess123", + cwd="/workspace", + resume_from_sidecar=True, + ) + + assert runner_cls.call_args.kwargs["prerequisite_resolution"] == prerequisites diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 7716f298..ab60f50f 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -69,6 +69,82 @@ "Ask user question", "Show architecture diagram", "Show candidate details", + "InfraGuard scan", + "Run InfraGuard static scan and return structured JSON results.", + "review step", + "configured feature", + "Direct binary download", + "Go install", + "Homebrew", + "Installing prerequisites...", + "Downloading prerequisites...", + "Initializing prerequisites...", + "Installing prerequisites with {installer}...", + ( + "Pipeline prerequisite {name} is missing and no configured installer is usable; " + "{feature} will be skipped for this run." + ), + ( + "Pipeline prerequisite {name} is missing; it is required for {feature}. " + "Choose an installer or skip this feature for this run." + ), + "Skip {feature} for this run", + "command timed out after {seconds} seconds", + "Version check failed for {name}: {reason}", + "Could not determine {name} version from output.", + "{name} version {version} is lower than required {minimum}.", + "error", + "passed", + "failed", + "completed", + "1 finding", + "{count} findings", + "blocking {count}", + "line {line}", + "Reason: {reason}", + "Recommendation: {recommendation}", + "Snippet: {snippet}", + "Command: {command}", + "Status: {status}", + "Error: {error}", + "File: {file_path}", + "Mode: {mode}", + "Exit code: {exit_code}", + "Ignore waivers: {value}", + "Blocking severities: {severities}", + "Blocking findings: {count}", + "Aspects: {aspects}", + "Policies:", + "Summary:", + "Severity counts: {counts}", + "Stderr: {stderr}", + "Findings:", + "No findings.", + ( + "reviewing ran write_file/edit_file after ros_validate_template; " + "rerun ros_validate_template and infraguard_scan for the same file_path." + ), + "reviewing must validate the repaired template with ros_validate_template for the same file_path.", + ( + "reviewing ran write_file/edit_file after the final InfraGuard scan; " + "rerun ros_validate_template and infraguard_scan for the same file_path." + ), + "reviewing must finish with a passing InfraGuard scan for the same file_path.", + ( + "This input still lacks a clear cloud resource, deployment target, or operations constraint; " + "clarify with the user first." + ), + ( + "The current flow only supports Alibaba Cloud deployment requests; ask the user to change the target " + "to Alibaba Cloud or confirm that it should not be handled for now." + ), + "Low-confidence intent cannot be completed directly; clarify with the user first.", + ( + "This input is not a deployment or cloud resource request; ask the user to provide a deployment target " + "or confirm that it should not be handled for now." + ), + "A successful deployment must wait until ros_deploy returns CREATE_COMPLETE.", + ("{feature} skipped\nPrerequisite {name} failed, so {feature} is disabled for this run.\nReason: {reason}"), 'Generated the architecture diagram for "{candidate_name}".', 'Displayed details for "{candidate_name}".', } diff --git a/tests/test_services/test_telemetry/test_content_serializer.py b/tests/test_services/test_telemetry/test_content_serializer.py index 39049253..ed65cf4b 100644 --- a/tests/test_services/test_telemetry/test_content_serializer.py +++ b/tests/test_services/test_telemetry/test_content_serializer.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import Any +from iac_code.providers.base import ContentBlock, Message from iac_code.services.telemetry.content_serializer import ( serialize_input_messages, serialize_output_messages, @@ -12,6 +13,7 @@ serialize_tool_definitions, serialize_tool_result, ) +from iac_code.tools.result_storage import ResultStorage @dataclass @@ -37,7 +39,7 @@ class FakeToolDef: @dataclass class FakeToolResult: - content: str + content: Any def test_serialize_input_messages_text(): @@ -74,6 +76,86 @@ def test_serialize_input_messages_tool_result(): assert part["response"] == "result output" +def test_serialize_input_messages_tool_result_redacts_embedded_file_content(): + blocks = [ + FakeContentBlock( + type="tool_result", + tool_use_id="t1", + text=json.dumps( + { + "file_content": "ROSTemplateFormatVersion: '2015-09-01'\nResources: {}\n", + "file_sha256": "sha256-value", + }, + ensure_ascii=False, + ), + ), + ] + result = json.loads(serialize_input_messages([FakeMessage(role="tool", content=blocks)])) + + response = result[0]["parts"][0]["response"] + assert "ROSTemplateFormatVersion" not in response + assert "file_content" in response + assert "sha256-value" in response + + +def test_serialize_input_messages_tool_result_uses_provider_content_field(): + result = json.loads( + serialize_input_messages( + [ + Message( + role="user", + content=[ + ContentBlock( + type="tool_result", + tool_use_id="t1", + content=json.dumps( + { + "file_content": "ROSTemplateFormatVersion: '2015-09-01'\nResources: {}\n", + "file_sha256": "sha256-value", + }, + ensure_ascii=False, + ), + ) + ], + ) + ] + ) + ) + + response = result[0]["parts"][0]["response"] + assert response + assert "ROSTemplateFormatVersion" not in response + assert "file_content" in response + assert "sha256-value" in response + + +def test_serialize_input_messages_tool_result_redacts_externalized_file_content_preview(tmp_path): + raw_result = json.dumps( + { + "file_sha256": "sha256-value", + "file_content": "ROSTemplateFormatVersion: '2015-09-01'\nResources: {}\n" + ("X" * 500), + }, + ensure_ascii=False, + ) + preview = ( + ResultStorage( + storage_dir=str(tmp_path / "tool-results"), + max_inline_chars=10, + preview_chars=180, + ) + .process("t1", raw_result) + .content + ) + assert "ROSTemplateFormatVersion" in preview + blocks = [FakeContentBlock(type="tool_result", tool_use_id="t1", text=preview)] + result = json.loads(serialize_input_messages([FakeMessage(role="tool", content=blocks)])) + + response = result[0]["parts"][0]["response"] + assert "ROSTemplateFormatVersion" not in response + assert "file_content" in response + assert "sha256-value" in response + + def test_serialize_output_messages(): result = json.loads(serialize_output_messages("Done!", "end_turn")) assert result[0]["role"] == "assistant" @@ -111,6 +193,41 @@ def test_serialize_tool_result_object(): assert "output" in result +def test_serialize_tool_result_redacts_embedded_file_content(): + result = serialize_tool_result( + FakeToolResult( + content=json.dumps( + { + "file_path": "templates/demo.yml", + "file_sha256": "sha256-value", + "file_content": "ROSTemplateFormatVersion: '2015-09-01'\nResources: {}\n", + }, + ensure_ascii=False, + ) + ) + ) + + assert "ROSTemplateFormatVersion" not in result + assert "file_content" in result + assert "sha256-value" in result + + +def test_serialize_tool_result_redacts_non_string_content(): + result = serialize_tool_result( + FakeToolResult( + content={ + "file_path": "templates/demo.yml", + "file_sha256": "sha256-value", + "file_content": "ROSTemplateFormatVersion: '2015-09-01'\nResources: {}\n", + } + ) + ) + + assert "ROSTemplateFormatVersion" not in result + assert "file_content" in result + assert "sha256-value" in result + + def test_truncation_for_large_content(): big = "x" * 10000 result = serialize_tool_arguments(big) diff --git a/tests/tools/test_tool_executor.py b/tests/tools/test_tool_executor.py index 4cfe73c3..385b5602 100644 --- a/tests/tools/test_tool_executor.py +++ b/tests/tools/test_tool_executor.py @@ -166,6 +166,35 @@ async def execute(self, *, tool_input, context): await executor.execute_batch(calls, ToolContext()) assert order == [0, 1, 2] + async def test_read_after_write_runs_after_write_in_same_batch(self): + state = {"value": "before"} + + class StateWrite(FakeWriteTool): + async def execute(self, *, tool_input, context): + state["value"] = tool_input["value"] + return ToolResult.success("written") + + class StateRead(FakeReadTool): + async def execute(self, *, tool_input, context): + return ToolResult.success(state["value"]) + + write_tool = StateWrite() + read_tool = StateRead() + registry = MagicMock() + registry.get = lambda name: write_tool if name == "write" else read_tool + executor = ToolExecutor(registry=registry) + + results = await executor.execute_batch( + [ + ToolCallRequest(id="write-1", name="write", input={"value": "after"}), + ToolCallRequest(id="read-1", name="read", input={}), + ], + ToolContext(), + ) + + assert results[0].content == "written" + assert results[1].content == "after" + async def test_error_no_block(self): class ErrorTool(FakeReadTool): async def execute(self, *, tool_input, context): @@ -241,6 +270,21 @@ async def execute(self, *, tool_input, context): assert results[0].content == "True" + async def test_env_overrides_are_preserved_in_derived_tool_context(self): + class ContextAwareTool(FakeReadTool): + async def execute(self, *, tool_input, context): + return ToolResult.success(context.env_overrides.get("PATH", "")) + + tool = ContextAwareTool() + registry = MagicMock() + registry.get = lambda name: tool + executor = ToolExecutor(registry=registry) + calls = [ToolCallRequest(id="a", name="read", input={})] + + results = await executor.execute_batch(calls, ToolContext(env_overrides={"PATH": "/tmp/bin"})) + + assert results[0].content == "/tmp/bin" + class FakeStrictTool(Tool): @property diff --git a/tests/ui/components/test_select.py b/tests/ui/components/test_select.py index 36c4bbec..c4f76442 100644 --- a/tests/ui/components/test_select.py +++ b/tests/ui/components/test_select.py @@ -298,6 +298,39 @@ def test_run_skips_none_key_events(self): assert result == "a" + def test_run_uses_supplied_console_and_clears_to_screen_end(self): + """run() should share the caller's console and clear leftover picker rows.""" + options = make_text_options() + sel = Select(options) + + enter_event = KeyEvent(key="enter", char="enter", ctrl=False) + mock_cap = MagicMock() + mock_cap.__enter__ = MagicMock(return_value=mock_cap) + mock_cap.__exit__ = MagicMock(return_value=False) + mock_cap.read_key.side_effect = [enter_event, None] + supplied_console = MagicMock() + renderers = [] + clear_kwargs = [] + + class FakeRenderer: + def __init__(self, console): + self.console = console + renderers.append(self) + + def render(self, _renderable): + return None + + def clear(self, **kwargs): + clear_kwargs.append(kwargs) + + with patch("iac_code.ui.core.raw_input.RawInputCapture", return_value=mock_cap): + with patch("iac_code.ui.core.in_place_render.InPlaceRenderer", FakeRenderer): + result = sel.run(console=supplied_console) + + assert result == "a" + assert renderers[0].console is supplied_console + assert clear_kwargs == [{"clear_to_screen_end": True}] + # --------------------------------------------------------------- # Lines 158-167: input-mode enter and delegate-to-search-box # --------------------------------------------------------------- diff --git a/tests/ui/core/test_in_place_render.py b/tests/ui/core/test_in_place_render.py index 6bd1cddd..ed41389e 100644 --- a/tests/ui/core/test_in_place_render.py +++ b/tests/ui/core/test_in_place_render.py @@ -55,6 +55,16 @@ def test_clear_erases_last_frame(self): assert out.count("\x1b[A\x1b[2K") == last - 1 assert renderer.last_height == 0 + def test_clear_can_erase_to_screen_end(self): + renderer, buf, _ = make_renderer() + renderer.render(Text("a\nb\nc")) + buf.seek(0) + buf.truncate() + renderer.clear(clear_to_screen_end=True) + out = buf.getvalue() + assert out.endswith("\x1b[J") + assert renderer.last_height == 0 + def test_clear_is_idempotent(self): renderer, buf, _ = make_renderer() renderer.render(Text("a\nb")) diff --git a/tests/ui/test_renderer_events.py b/tests/ui/test_renderer_events.py index 8d8354b9..916faee9 100644 --- a/tests/ui/test_renderer_events.py +++ b/tests/ui/test_renderer_events.py @@ -365,6 +365,38 @@ async def _idle_key_listener(*_args, **_kwargs): class TestRendererToolResultFallback: + @pytest.mark.asyncio + async def test_uses_renderer_tool_when_tool_registry_missing(self, monkeypatch): + monkeypatch.setattr(Renderer, "_key_listener", _idle_key_listener) + renderer = _make_renderer_for_tool_result_fallback_test() + renderer_tool = MagicMock() + renderer_tool.user_facing_name.return_value = "InfraGuard scan" + renderer_tool.render_tool_use_message.return_value = None + renderer_tool.render_tool_result_message.side_effect = lambda output, *, is_error=False, verbose=False: ( + "Command: infraguard scan\nStatus: passed" if verbose else "passed · 0 findings" + ) + + await renderer.run_streaming_output( + _tool_result_events( + ToolUseStartEvent( + tool_use_id="tool-a", + name="infraguard_scan", + renderer_tool=renderer_tool, + ), + ToolUseEndEvent(tool_use_id="tool-a", name="infraguard_scan", input={}), + ToolResultEvent( + tool_use_id="tool-a", + tool_name="infraguard_scan", + result='{"command": ["infraguard", "scan"]}', + ), + ), + _allow_permission, + ) + + output = renderer.console.file.getvalue() + assert "passed · 0 findings" in output + assert '{"command"' not in output + @pytest.mark.asyncio async def test_does_not_fallback_stale_tool_result_id_to_same_named_tool(self, monkeypatch): monkeypatch.setattr(Renderer, "_key_listener", _idle_key_listener) diff --git a/tests/ui/test_renderer_helpers.py b/tests/ui/test_renderer_helpers.py index 2fc05218..09d6a214 100644 --- a/tests/ui/test_renderer_helpers.py +++ b/tests/ui/test_renderer_helpers.py @@ -273,6 +273,31 @@ def test_render_tool_result_uses_tool_summary(self): assert line is not None assert "done" in str(line) + def test_render_tool_result_uses_record_renderer_tool_when_registry_missing(self): + renderer = Renderer(make_console(), ToolRegistry(), status_callback=lambda: "ready") + renderer_tool = MagicMock() + renderer_tool.render_tool_result_message.side_effect = lambda output, *, is_error=False, verbose=False: ( + "Command: infraguard scan\nStatus: passed" if verbose else "passed · 0 findings" + ) + rec = _ToolCallRecord( + tool_name="infraguard_scan", + tool_input={}, + done=True, + result='{"command": ["infraguard", "scan"]}', + renderer_tool=renderer_tool, + ) + + compact = renderer._render_tool_result(rec) + assert compact is not None + assert "passed · 0 findings" in compact.plain + assert '{"command"' not in compact.plain + + renderer._verbose = True + verbose = renderer._render_tool_result(rec) + assert verbose is not None + assert "Command: infraguard scan" in verbose.plain + assert '{"command"' not in verbose.plain + def test_render_progress_groups_include_resource_rows(self): renderer = make_renderer() @@ -412,6 +437,15 @@ def test_render_tool_result_summarizes_pipeline_ros_deploy_without_registry_tool assert "single-vswitch-20260706-k7m3x9 creation failed: CIDR block overlapped (a463b158)" in line.plain assert "status_reason" not in line.plain + def test_render_tool_header_localizes_infraguard_scan_tool_name(self): + renderer = make_renderer() + record = _ToolCallRecord(tool_name="infraguard_scan", tool_input={}, done=True) + + header = renderer._render_tool_header(record) + + assert "InfraGuard scan" in header.plain + assert "infraguard_scan" not in header.plain + def test_print_segments_to_scrollback_archives_and_merges_assistant_turns(self): renderer = make_renderer() diff --git a/tests/ui/test_repl_swap_session_pipeline.py b/tests/ui/test_repl_swap_session_pipeline.py index e731328b..d63b243d 100644 --- a/tests/ui/test_repl_swap_session_pipeline.py +++ b/tests/ui/test_repl_swap_session_pipeline.py @@ -1,11 +1,21 @@ """Regression tests for /resume swap during pipeline mode (问题 4).""" +import os +import time from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest +import yaml from iac_code.pipeline.config import RunMode +from iac_code.pipeline.engine.prerequisites import ( + InstallerSpec, + PrerequisiteDecision, + PrerequisiteProgress, + PrerequisiteResolution, +) from iac_code.pipeline.engine.user_input import PipelineUserInput @@ -30,6 +40,14 @@ def _make_repl_with_pipeline(tmp_path: Path, session_id_old: str, session_id_new repl._load_current_session_name = MagicMock(return_value=None) repl.swap_session = InlineREPL.swap_session.__get__(repl) repl._set_runtime_mode = InlineREPL._set_runtime_mode.__get__(repl) + repl._prepare_pipeline_prerequisite_metadata = InlineREPL._prepare_pipeline_prerequisite_metadata.__get__(repl) + repl._sidecar_prerequisite_metadata = InlineREPL._sidecar_prerequisite_metadata.__get__(repl) + repl._load_pipeline_raw_config = InlineREPL._load_pipeline_raw_config.__get__(repl) + repl._apply_pipeline_prerequisite_env_overrides = InlineREPL._apply_pipeline_prerequisite_env_overrides + repl._print_pipeline_prerequisite_status_messages = InlineREPL._print_pipeline_prerequisite_status_messages.__get__( + repl + ) + repl._pipeline_prerequisite_feature_name = InlineREPL._pipeline_prerequisite_feature_name.__get__(repl) sessions_root = tmp_path / "projects" / "proj" sessions_root.mkdir(parents=True) @@ -44,6 +62,800 @@ def _make_repl_with_pipeline(tmp_path: Path, session_id_old: str, session_id_new return repl, sessions_root +def _write_pipeline_yaml(pipeline_dir: Path) -> None: + pipeline_dir.mkdir(parents=True) + (pipeline_dir / "pipeline.yaml").write_text( + yaml.safe_dump( + { + "name": "test-pipeline", + "feature_flags": {"reviewing": {"default": True, "display_key": "review_step"}}, + "prerequisites": { + "infraguard": { + "command": "infraguard", + "required_by_flags": ["reviewing"], + } + }, + } + ), + encoding="utf-8", + ) + + +def _make_repl_for_pipeline_chat(tmp_path: Path): + from iac_code.ui.repl import InlineREPL + + repl = MagicMock(spec=InlineREPL) + repl._pipeline = None + repl._pipeline_waiting_input = False + repl._pipeline_restored_status = None + repl._pipeline_state_persistence_failed = False + repl._session_id = "session-1" + repl._original_cwd = str(tmp_path) + repl._provider_manager = MagicMock() + repl.tool_registry = MagicMock() + repl._session_storage = MagicMock() + repl._session_storage.session_dir.side_effect = lambda cwd, sid: tmp_path / sid + repl.store = MagicMock() + repl.store.get_state.return_value = SimpleNamespace(permission_context=None) + repl.console = MagicMock() + repl.console.print = MagicMock() + repl.renderer = MagicMock() + repl.renderer.record_user_turn = MagicMock() + repl.command_registry = MagicMock() + repl.command_registry.get_model_invocable_skills.return_value = [] + repl._pipeline_memory_content_getter = MagicMock(return_value=lambda: "") + repl._refresh_pipeline_display_recorder = MagicMock() + repl._detect_pipeline_session = MagicMock(return_value=False) + repl._persist_pipeline_visible_user_turn = MagicMock() + repl._render_pipeline_stream = AsyncMock(return_value=None) + repl._finalize_pipeline_after_render = MagicMock() + repl._flush_pipeline_telemetry = AsyncMock() + repl._maybe_start_pipeline_cleanup = AsyncMock() + repl._handle_pipeline_chat = InlineREPL._handle_pipeline_chat.__get__(repl) + repl._prepare_pipeline_prerequisite_metadata = InlineREPL._prepare_pipeline_prerequisite_metadata.__get__(repl) + repl._sidecar_prerequisite_metadata = InlineREPL._sidecar_prerequisite_metadata.__get__(repl) + repl._load_pipeline_raw_config = InlineREPL._load_pipeline_raw_config.__get__(repl) + repl._apply_pipeline_prerequisite_env_overrides = InlineREPL._apply_pipeline_prerequisite_env_overrides + repl._print_pipeline_prerequisite_status_messages = InlineREPL._print_pipeline_prerequisite_status_messages.__get__( + repl + ) + repl._pipeline_prerequisite_feature_name = InlineREPL._pipeline_prerequisite_feature_name.__get__(repl) + return repl + + +class _FakePipeline: + sidecar_restore_result = None + + def run(self, _pipeline_input): + return _empty_stream() + + +def test_pipeline_prerequisite_choice_announces_missing_required_review_prerequisite(monkeypatch): + from iac_code.ui import repl as repl_module + from iac_code.ui.repl import InlineREPL + + repl = MagicMock(spec=InlineREPL) + repl.renderer = MagicMock() + repl._pipeline_prerequisite_required_flags_by_name = {"infraguard": ["enable_reviewing"]} + repl._pipeline_prerequisite_feature_labels_by_flag = {"enable_reviewing": "review step"} + repl._pipeline_prerequisite_choice = InlineREPL._pipeline_prerequisite_choice.__get__(repl) + repl._pipeline_prerequisite_feature_name = InlineREPL._pipeline_prerequisite_feature_name.__get__(repl) + repl._pipeline_prerequisite_feature_label = InlineREPL._pipeline_prerequisite_feature_label.__get__(repl) + + class FakeSelect: + def __init__(self, options, default_value=None, layout=None): + self.options = options + self.default_value = default_value + self.layout = layout + + def run(self, **kwargs): + return "skip" + + monkeypatch.setattr(repl_module, "Select", FakeSelect) + + result = repl._pipeline_prerequisite_choice( + "infraguard", + [ + InstallerSpec( + id="go-install", + platforms=["darwin", "linux", "windows"], + requires_commands=["go"], + ) + ], + ) + + assert result is None + messages = [call.args[0] for call in repl.renderer.print_system_message.call_args_list] + assert any("infraguard" in message and "review" in message and "missing" in message.lower() for message in messages) + + +def test_pipeline_prerequisite_choice_uses_translated_installer_labels_and_repl_console(monkeypatch): + from iac_code.ui import repl as repl_module + from iac_code.ui.repl import InlineREPL + + repl = MagicMock(spec=InlineREPL) + repl.console = MagicMock() + repl.renderer = MagicMock() + repl._pipeline_prerequisite_required_flags_by_name = {"infraguard": ["enable_reviewing"]} + repl._pipeline_prerequisite_feature_labels_by_flag = {"enable_reviewing": "review step"} + repl._pipeline_prerequisite_choice = InlineREPL._pipeline_prerequisite_choice.__get__(repl) + repl._pipeline_prerequisite_feature_name = InlineREPL._pipeline_prerequisite_feature_name.__get__(repl) + repl._pipeline_prerequisite_feature_label = InlineREPL._pipeline_prerequisite_feature_label.__get__(repl) + + captured = {} + + class FakeSelect: + def __init__(self, options, default_value=None, layout=None): + captured["options"] = options + captured["default_value"] = default_value + captured["layout"] = layout + + def run(self, **kwargs): + captured["run_kwargs"] = kwargs + return "go-install" + + monkeypatch.setattr(repl_module, "Select", FakeSelect) + + result = repl._pipeline_prerequisite_choice( + "infraguard", + [ + InstallerSpec(id="direct-binary", platforms=["darwin"], display_key="direct_binary_download"), + InstallerSpec(id="homebrew", platforms=["darwin"], display_key="homebrew"), + InstallerSpec(id="go-install", platforms=["darwin"], display_key="go_install"), + ], + ) + + assert result == "go-install" + labels = [option.label for option in captured["options"]] + assert labels[:3] == ["Direct binary download", "Homebrew", "Go install"] + assert "direct-binary" not in labels + assert captured["run_kwargs"] == {"console": repl.console} + + +def test_pipeline_prerequisite_choice_uses_generic_feature_label_for_non_review_prerequisite(monkeypatch): + from iac_code.ui import repl as repl_module + from iac_code.ui.repl import InlineREPL + + repl = MagicMock(spec=InlineREPL) + repl.renderer = MagicMock() + repl._pipeline_prerequisite_required_flags_by_name = {"custom_tool": ["enable_custom_feature"]} + repl._pipeline_prerequisite_choice = InlineREPL._pipeline_prerequisite_choice.__get__(repl) + repl._pipeline_prerequisite_feature_name = InlineREPL._pipeline_prerequisite_feature_name.__get__(repl) + repl._pipeline_prerequisite_feature_label = InlineREPL._pipeline_prerequisite_feature_label.__get__(repl) + + class FakeSelect: + def __init__(self, options, default_value=None, layout=None): + self.options = options + + def run(self, **kwargs): + return "skip" + + monkeypatch.setattr(repl_module, "Select", FakeSelect) + + result = repl._pipeline_prerequisite_choice( + "custom_tool", + [ + InstallerSpec( + id="custom-install", + platforms=["linux"], + ) + ], + ) + + assert result is None + messages = [call.args[0] for call in repl.renderer.print_system_message.call_args_list] + assert any("configured feature" in message for message in messages) + assert all("review" not in message.lower() for message in messages) + + +def test_pipeline_prerequisite_choice_uses_configured_feature_label(monkeypatch): + from iac_code.ui import repl as repl_module + from iac_code.ui.repl import InlineREPL + + repl = MagicMock(spec=InlineREPL) + repl.renderer = MagicMock() + repl._pipeline_prerequisite_required_flags_by_name = {"cost_tool": ["enable_cost_estimation"]} + repl._pipeline_prerequisite_feature_labels_by_flag = {"enable_cost_estimation": "cost estimation"} + repl._pipeline_prerequisite_choice = InlineREPL._pipeline_prerequisite_choice.__get__(repl) + repl._pipeline_prerequisite_feature_name = InlineREPL._pipeline_prerequisite_feature_name.__get__(repl) + repl._pipeline_prerequisite_feature_label = InlineREPL._pipeline_prerequisite_feature_label.__get__(repl) + + class FakeSelect: + def __init__(self, options, default_value=None, layout=None): + self.options = options + + def run(self, **kwargs): + return "skip" + + monkeypatch.setattr(repl_module, "Select", FakeSelect) + + result = repl._pipeline_prerequisite_choice( + "cost_tool", + [ + InstallerSpec( + id="cost-install", + platforms=["linux"], + ) + ], + ) + + assert result is None + messages = [call.args[0] for call in repl.renderer.print_system_message.call_args_list] + assert any("cost estimation" in message for message in messages) + assert all("configured feature" not in message for message in messages) + assert all("review" not in message.lower() for message in messages) + + +def test_pipeline_prerequisite_status_message_uses_configured_feature_label(): + from iac_code.ui.repl import InlineREPL + + repl = MagicMock(spec=InlineREPL) + repl.renderer = MagicMock() + repl.console = MagicMock() + repl._pipeline_prerequisite_feature_labels_by_flag = {"enable_cost_estimation": "cost estimation"} + repl._print_pipeline_prerequisite_status_messages = InlineREPL._print_pipeline_prerequisite_status_messages.__get__( + repl + ) + repl._pipeline_prerequisite_feature_name = InlineREPL._pipeline_prerequisite_feature_name.__get__(repl) + + repl._print_pipeline_prerequisite_status_messages( + { + "feature_flags": {"enable_cost_estimation": False}, + "decisions": { + "cost_tool": { + "status": "install_failed", + "required_flags": ["enable_cost_estimation"], + "message": "install failed", + } + }, + } + ) + + messages = [call.args[0] for call in repl.renderer.print_system_message.call_args_list] + assert any("cost estimation skipped" in message for message in messages) + assert all("Review step skipped" not in message for message in messages) + + +def test_pipeline_prerequisite_progress_display_refreshes_without_new_output(monkeypatch): + from iac_code.ui import repl as repl_module + + updates = [] + + class FakeRenderer: + def __init__(self, console): + self.console = console + + def render(self, renderable): + updates.append(("render", renderable)) + + def clear(self, **kwargs): + updates.append(("clear", kwargs)) + + monkeypatch.setattr(repl_module, "InPlaceRenderer", FakeRenderer) + + display = repl_module._PipelinePrerequisiteProgressDisplay(MagicMock(width=100), refresh_interval=0.01) + display.handle( + PrerequisiteProgress( + name="infraguard", + installer_id="homebrew", + phase="install", + status="started", + message="Running brew tap", + command=["brew", "tap", "aliyun/infraguard"], + ) + ) + time.sleep(0.05) + display.close() + + render_updates = [update for update in updates if update[0] == "render"] + assert len(render_updates) >= 2 + assert updates[-1] == ("clear", {"clear_to_screen_end": True}) + + +def test_pipeline_prerequisite_progress_display_can_resume_after_clear(monkeypatch): + from iac_code.ui import repl as repl_module + + updates = [] + + class FakeRenderer: + def __init__(self, console): + self.console = console + + def render(self, renderable): + updates.append(("render", renderable)) + + def clear(self, **kwargs): + updates.append(("clear", kwargs)) + + monkeypatch.setattr(repl_module, "InPlaceRenderer", FakeRenderer) + + display = repl_module._PipelinePrerequisiteProgressDisplay(MagicMock(width=100), refresh_interval=10) + display.handle( + PrerequisiteProgress( + name="infraguard", + installer_id="go-install", + phase="path_hint", + status="output", + message="/Users/ehzyo/go", + ) + ) + display.clear() + display.handle( + PrerequisiteProgress( + name="infraguard", + installer_id="go-install", + phase="install", + status="started", + message="Installing infraguard", + ) + ) + display.close() + + assert [update[0] for update in updates] == ["render", "clear", "render", "clear"] + assert updates[1] == ("clear", {"clear_to_screen_end": True}) + assert updates[3] == ("clear", {"clear_to_screen_end": True}) + + +def test_pipeline_prerequisite_progress_display_formats_human_readable_lines(): + from iac_code.ui import repl as repl_module + + display = repl_module._PipelinePrerequisiteProgressDisplay(MagicMock(width=110)) + + started = display._format_event( + PrerequisiteProgress( + name="infraguard", + installer_id="homebrew", + phase="install", + status="started", + message="Running brew tap aliyun/infraguard https://github.com/aliyun/infraguard", + command=["brew", "tap", "aliyun/infraguard", "https://github.com/aliyun/infraguard"], + ) + ) + output = display._format_event( + PrerequisiteProgress( + name="infraguard", + installer_id="homebrew", + phase="install", + status="output", + message="==> Tapping aliyun/infraguard", + command=["brew", "tap", "aliyun/infraguard", "https://github.com/aliyun/infraguard"], + ) + ) + + assert started == "Running: brew tap aliyun/infraguard ..." + assert output == "==> Tapping aliyun/infraguard" + assert "install:output" not in output + assert "[homebrew]" not in started + + +def test_pipeline_prerequisite_progress_display_formats_download_progress(): + from iac_code.ui import repl as repl_module + + display = repl_module._PipelinePrerequisiteProgressDisplay(MagicMock(width=110)) + + known_total = display._format_event( + PrerequisiteProgress( + name="infraguard", + installer_id="direct-binary", + phase="download", + status="output", + message="Downloading infraguard-v0.10.0-darwin-arm64: 40% (8.0 MB / 20.0 MB)", + command=["download", "infraguard-v0.10.0-darwin-arm64"], + downloaded_bytes=8 * 1024 * 1024, + total_bytes=20 * 1024 * 1024, + ) + ) + unknown_total = display._format_event( + PrerequisiteProgress( + name="infraguard", + installer_id="direct-binary", + phase="download", + status="output", + message="Downloading infraguard-v0.10.0-darwin-arm64: 1.5 MB downloaded", + command=["download", "infraguard-v0.10.0-darwin-arm64"], + downloaded_bytes=1536 * 1024, + ) + ) + + assert known_total == "Download: 40% (8.0 MB / 20.0 MB)" + assert unknown_total == "Download: 1.5 MB downloaded" + + +def test_pipeline_prerequisite_progress_display_uses_translated_installer_label(): + from iac_code.ui import repl as repl_module + + display = repl_module._PipelinePrerequisiteProgressDisplay(MagicMock(width=110)) + + status = display._status_for_event( + PrerequisiteProgress( + name="infraguard", + installer_id="go-install", + installer_display_key="go_install", + phase="install", + status="started", + message="Installing infraguard", + ) + ) + + assert status == "Installing prerequisites with Go install..." + assert "go-install" not in status + + +def test_pipeline_prerequisite_progress_display_updates_download_progress_in_place(): + from iac_code.ui import repl as repl_module + + display = repl_module._PipelinePrerequisiteProgressDisplay(MagicMock(width=110)) + + for downloaded in (1, 2, 3): + display.handle( + PrerequisiteProgress( + name="infraguard", + installer_id="direct-binary", + phase="download", + status="output", + message=f"Downloading infraguard-v0.10.0-darwin-arm64: {downloaded} MB", + command=["download", "infraguard-v0.10.0-darwin-arm64"], + downloaded_bytes=downloaded * 1024 * 1024, + total_bytes=54 * 1024 * 1024, + ) + ) + + rendered = display._render().renderables[1].plain + display.close() + + assert "Download: 6% (3.0 MB / 54.0 MB)" in rendered + assert "Download: 2%" not in rendered + assert "Download: 4%" not in rendered + + +def test_pipeline_prerequisite_progress_display_hides_stale_path_hint_during_download(): + from iac_code.ui import repl as repl_module + + display = repl_module._PipelinePrerequisiteProgressDisplay(MagicMock(width=110)) + display.handle( + PrerequisiteProgress( + name="infraguard", + installer_id="go-install", + phase="path_hint", + status="output", + message="/Users/ehzyo/go", + ) + ) + display.handle( + PrerequisiteProgress( + name="infraguard", + installer_id="direct-binary", + phase="download", + status="output", + message="Downloading infraguard-v0.10.1-darwin-arm64: 9% (5.0 MB / 55.1 MB)", + command=["download", "infraguard-v0.10.1-darwin-arm64"], + downloaded_bytes=5 * 1024 * 1024, + total_bytes=55 * 1024 * 1024, + ) + ) + + rendered = display._render().renderables[1].plain + display.close() + + assert "Download: 9%" in rendered + assert "/Users/ehzyo/go" not in rendered + + +def test_pipeline_prerequisite_prepare_installs_immediate_sigint_handler(tmp_path, monkeypatch): + from iac_code.ui import repl as repl_module + + repl = _make_repl_for_pipeline_chat(tmp_path) + pipeline_dir = tmp_path / "pipeline-def" + _write_pipeline_yaml(pipeline_dir) + progress_display = MagicMock() + installed_handlers = [] + previous_handler = object() + wakeup_fds = [] + + repl._load_pipeline_raw_config = MagicMock( + return_value={ + "feature_flags": {"reviewing": {"default": True}}, + "prerequisites": { + "infraguard": { + "command": "infraguard", + "required_by_flags": ["reviewing"], + } + }, + } + ) + repl._make_pipeline_prerequisite_progress_display = MagicMock(return_value=progress_display) + repl._pipeline_prerequisite_choice = MagicMock() + original_signal = repl_module.signal.signal + original_getsignal = repl_module.signal.getsignal + + def fake_prepare(*args, **kwargs): + assert installed_handlers + installed_handlers[-1](repl_module.signal.SIGINT, None) + raise AssertionError("SIGINT handler should raise KeyboardInterrupt") + + def fake_signal(signum, handler): + if signum != repl_module.signal.SIGINT: + return original_signal(signum, handler) + installed_handlers.append(handler) + return previous_handler + + monkeypatch.setattr(repl_module, "prepare_prerequisites", fake_prepare) + monkeypatch.setattr( + repl_module.signal, + "getsignal", + lambda signum: previous_handler if signum == repl_module.signal.SIGINT else original_getsignal(signum), + ) + monkeypatch.setattr(repl_module.signal, "signal", fake_signal) + monkeypatch.setattr(repl_module.signal, "set_wakeup_fd", lambda fd: wakeup_fds.append(fd) or 42) + + with pytest.raises(KeyboardInterrupt): + repl._prepare_pipeline_prerequisite_metadata( + pipeline_name="test-pipeline", + cwd=str(tmp_path), + session_id="session-1", + ) + + assert installed_handlers[-1] is previous_handler + assert wakeup_fds == [-1, 42] + progress_display.close.assert_called_once() + + +def test_pipeline_prerequisite_prepare_clears_progress_before_installer_choice(tmp_path, monkeypatch): + from iac_code.ui import repl as repl_module + + repl = _make_repl_for_pipeline_chat(tmp_path) + order = [] + resolution = PrerequisiteResolution( + feature_flags={"reviewing": False}, + decisions={ + "infraguard": PrerequisiteDecision( + name="infraguard", + command="infraguard", + status="declined_or_unavailable", + required_flags=["reviewing"], + ) + }, + ) + + class FakeProgressDisplay: + def handle(self, event): + order.append(("handle", event.phase)) + + def clear(self): + order.append(("clear", None)) + + def close(self): + order.append(("close", None)) + + def fake_prepare(raw_prerequisites, *, feature_flags, surface, choose_installer, progress_handler=None): + assert progress_handler is not None + progress_handler( + PrerequisiteProgress( + name="infraguard", + installer_id="go-install", + phase="path_hint", + status="output", + message="/Users/ehzyo/go", + ) + ) + choose_installer("infraguard", []) + return resolution + + def fake_choice(name, installers): + order.append(("choice", name)) + return None + + repl._load_pipeline_raw_config = MagicMock( + return_value={ + "feature_flags": {"reviewing": {"default": True}}, + "prerequisites": { + "infraguard": { + "command": "infraguard", + "required_by_flags": ["reviewing"], + } + }, + } + ) + repl._make_pipeline_prerequisite_progress_display = MagicMock(return_value=FakeProgressDisplay()) + repl._pipeline_prerequisite_choice = fake_choice + monkeypatch.setattr(repl_module, "prepare_prerequisites", fake_prepare) + + repl._prepare_pipeline_prerequisite_metadata( + pipeline_name="test-pipeline", + cwd=str(tmp_path), + session_id="session-1", + ) + + assert order == [ + ("handle", "path_hint"), + ("clear", None), + ("choice", "infraguard"), + ("close", None), + ] + + +@pytest.mark.asyncio +async def test_handle_pipeline_chat_prepares_prerequisites_before_create(tmp_path, monkeypatch): + from iac_code.ui import repl as repl_module + + repl = _make_repl_for_pipeline_chat(tmp_path) + pipeline_dir = tmp_path / "pipeline-def" + _write_pipeline_yaml(pipeline_dir) + resolution = PrerequisiteResolution( + feature_flags={"reviewing": False}, + decisions={ + "infraguard": PrerequisiteDecision( + name="infraguard", + command="infraguard", + status="declined_or_unavailable", + required_flags=["reviewing"], + ) + }, + env_overrides={"INFRAGUARD_PATH": "/tmp/infraguard"}, + ) + prepare_calls = [] + create_kwargs = {} + choice_calls = [] + + def choice(name, installers): + choice_calls.append((name, installers)) + return None + + repl._pipeline_prerequisite_choice = choice + + def fake_prepare(raw_prerequisites, *, feature_flags, surface, choose_installer, progress_handler=None): + assert progress_handler is not None + progress_handler( + PrerequisiteProgress( + name="infraguard", + installer_id="go-install", + phase="install", + status="started", + message="Installing infraguard", + ) + ) + prepare_calls.append( + { + "raw_prerequisites": raw_prerequisites, + "feature_flags": feature_flags, + "surface": surface, + "choose_installer": choose_installer, + "progress_handler": progress_handler, + } + ) + return resolution + + def fake_create_pipeline(*args, **kwargs): + create_kwargs.update(kwargs) + return _FakePipeline() + + monkeypatch.delenv("INFRAGUARD_PATH", raising=False) + monkeypatch.setattr("iac_code.pipeline.discover_pipelines", lambda: {"test-pipeline": pipeline_dir}) + monkeypatch.setattr("iac_code.pipeline.config.get_pipeline_name", lambda: "test-pipeline") + monkeypatch.setattr("iac_code.pipeline.config.get_working_directory", lambda: None) + monkeypatch.setattr(repl_module, "prepare_prerequisites", fake_prepare, raising=False) + monkeypatch.setattr("iac_code.pipeline.create_pipeline", fake_create_pipeline) + + await repl._handle_pipeline_chat("deploy") + + assert prepare_calls[0]["raw_prerequisites"] == { + "infraguard": { + "command": "infraguard", + "required_by_flags": ["reviewing"], + } + } + assert prepare_calls[0]["feature_flags"] == {"reviewing": True} + assert prepare_calls[0]["surface"] == "repl" + assert prepare_calls[0]["progress_handler"] is not None + assert prepare_calls[0]["choose_installer"] is not choice + assert prepare_calls[0]["choose_installer"]("infraguard", []) is None + assert choice_calls == [("infraguard", [])] + repl._make_pipeline_prerequisite_progress_display.return_value.clear.assert_called_once() + assert create_kwargs["prerequisite_resolution"] == resolution.to_metadata() + assert "INFRAGUARD_PATH" not in os.environ + + +@pytest.mark.asyncio +async def test_handle_pipeline_chat_reports_disabled_review_prerequisite_status(tmp_path, monkeypatch): + from iac_code.ui import repl as repl_module + + repl = _make_repl_for_pipeline_chat(tmp_path) + pipeline_dir = tmp_path / "pipeline-def" + _write_pipeline_yaml(pipeline_dir) + resolution = PrerequisiteResolution( + feature_flags={"reviewing": False}, + decisions={ + "infraguard": PrerequisiteDecision( + name="infraguard", + command="infraguard", + status="install_failed", + required_flags=["reviewing"], + installer_id="go-install", + message="install failed", + ) + }, + ) + create_kwargs = {} + + def fake_prepare(raw_prerequisites, *, feature_flags, surface, choose_installer, progress_handler=None): + return resolution + + def fake_create_pipeline(*args, **kwargs): + create_kwargs.update(kwargs) + return _FakePipeline() + + monkeypatch.setattr("iac_code.pipeline.discover_pipelines", lambda: {"test-pipeline": pipeline_dir}) + monkeypatch.setattr("iac_code.pipeline.config.get_pipeline_name", lambda: "test-pipeline") + monkeypatch.setattr("iac_code.pipeline.config.get_working_directory", lambda: None) + monkeypatch.setattr(repl_module, "prepare_prerequisites", fake_prepare, raising=False) + monkeypatch.setattr("iac_code.pipeline.create_pipeline", fake_create_pipeline) + + await repl._handle_pipeline_chat("deploy") + + assert create_kwargs["prerequisite_resolution"] == resolution.to_metadata() + messages = [call.args[0] for call in repl.renderer.print_system_message.call_args_list] + assert any( + "review" in message and "disabled" in message.lower() and "install failed" in message for message in messages + ) + assert any("review step skipped" in message for message in messages) + assert repl.console.print.call_count >= 1 + + +@pytest.mark.asyncio +async def test_handle_pipeline_chat_ignores_stale_terminal_sidecar_prerequisites(tmp_path, monkeypatch): + from iac_code.ui import repl as repl_module + + repl = _make_repl_for_pipeline_chat(tmp_path) + pipeline_dir = tmp_path / "pipeline-def" + _write_pipeline_yaml(pipeline_dir) + sidecar = tmp_path / "session-1" / "pipeline" + sidecar.mkdir(parents=True) + (sidecar / "meta.yaml").write_text( + yaml.safe_dump( + { + "status": "discarded", + "updated_at": 0.0, + "prerequisites": {"feature_flags": {"reviewing": False}, "decisions": {}, "env_overrides": {}}, + } + ), + encoding="utf-8", + ) + resolution = PrerequisiteResolution( + feature_flags={"reviewing": True}, + decisions={ + "infraguard": PrerequisiteDecision( + name="infraguard", + command="infraguard", + status="available", + required_flags=["reviewing"], + resolved_path="/usr/local/bin/infraguard", + ) + }, + ) + prepare_calls = [] + create_kwargs = {} + + def fake_prepare(raw_prerequisites, *, feature_flags, surface, choose_installer, progress_handler=None): + prepare_calls.append((raw_prerequisites, feature_flags, surface, choose_installer)) + return resolution + + def fake_create_pipeline(*args, **kwargs): + create_kwargs.update(kwargs) + return _FakePipeline() + + monkeypatch.setattr("iac_code.pipeline.discover_pipelines", lambda: {"test-pipeline": pipeline_dir}) + monkeypatch.setattr("iac_code.pipeline.config.get_pipeline_name", lambda: "test-pipeline") + monkeypatch.setattr("iac_code.pipeline.config.get_working_directory", lambda: None) + monkeypatch.setattr(repl_module, "prepare_prerequisites", fake_prepare, raising=False) + monkeypatch.setattr("iac_code.pipeline.create_pipeline", fake_create_pipeline) + + await repl._handle_pipeline_chat("deploy") + + assert len(prepare_calls) == 1 + assert create_kwargs["prerequisite_resolution"] == resolution.to_metadata() + repl._detect_pipeline_session.assert_called_once_with(str(tmp_path), "session-1") + + @pytest.mark.asyncio async def test_swap_session_clears_pipeline_reference(tmp_path): """问题 4-a:swap 后 self._pipeline 必须 None(防 sidecar 污染)。""" @@ -192,17 +1004,31 @@ async def test_swap_session_discarded_sidecar_does_not_prompt(tmp_path): @pytest.mark.asyncio -async def test_swap_session_resume_choice_creates_pipeline(tmp_path): +async def test_swap_session_resume_choice_creates_pipeline(tmp_path, monkeypatch): """问题 4-d:用户选 resume → 重建 self._pipeline。""" import yaml + from iac_code.ui import repl as repl_module from iac_code.ui.repl import InlineREPL repl, sessions_root = _make_repl_with_pipeline(tmp_path, "old", "new") sidecar = sessions_root / "new" / "pipeline" sidecar.mkdir(parents=True) + stored_prerequisites = { + "feature_flags": {"reviewing": False}, + "decisions": {}, + "env_overrides": {"INFRAGUARD_PATH": "/tmp/resumed/infraguard"}, + } (sidecar / "meta.yaml").write_text( - yaml.dump({"status": "running", "current_step": "step1", "state_machine": {}, "updated_at": 0.0}), + yaml.dump( + { + "status": "running", + "current_step": "step1", + "state_machine": {}, + "updated_at": 0.0, + "prerequisites": stored_prerequisites, + } + ), encoding="utf-8", ) @@ -213,21 +1039,78 @@ async def test_swap_session_resume_choice_creates_pipeline(tmp_path): repl._memory_manager = None repl.command_registry = MagicMock() repl.command_registry.get_model_invocable_skills.return_value = ["mocked_skill"] + monkeypatch.delenv("INFRAGUARD_PATH", raising=False) fake_pipeline = MagicMock() fake_pipeline.sidecar_restore_result = MagicMock(ok=True, status="running", reason=None) - with patch("iac_code.pipeline.create_pipeline", return_value=fake_pipeline) as cp: + with ( + patch("iac_code.pipeline.create_pipeline", return_value=fake_pipeline) as cp, + patch.object( + repl_module, + "prepare_prerequisites", + side_effect=AssertionError("stored sidecar prerequisites should not be prepared again"), + ), + ): await repl.swap_session_async("new") cp.assert_called_once() assert cp.call_args.kwargs["resume_from_sidecar"] is True assert cp.call_args.kwargs["session_id"] == "new" + assert cp.call_args.kwargs["prerequisite_resolution"] == stored_prerequisites + assert "INFRAGUARD_PATH" not in os.environ # Regression: /resume path must forward auto_trigger_skills so model-invocable # skills survive a session swap mid-pipeline. assert cp.call_args.kwargs.get("auto_trigger_skills") == ["mocked_skill"] assert repl._pipeline is fake_pipeline +@pytest.mark.asyncio +async def test_swap_session_resume_choice_empty_sidecar_prerequisites_skip_prepare(tmp_path): + import yaml + + from iac_code.ui import repl as repl_module + from iac_code.ui.repl import InlineREPL + + repl, sessions_root = _make_repl_with_pipeline(tmp_path, "old", "new") + sidecar = sessions_root / "new" / "pipeline" + sidecar.mkdir(parents=True) + (sidecar / "meta.yaml").write_text( + yaml.dump( + { + "status": "running", + "current_step": "step1", + "state_machine": {}, + "updated_at": 0.0, + "prerequisites": {}, + } + ), + encoding="utf-8", + ) + + repl.swap_session_async = InlineREPL.swap_session_async.__get__(repl) + repl._confirm_pipeline_resume = AsyncMock(return_value="resume") + repl._provider_manager = MagicMock() + repl.tool_registry = MagicMock() + repl._memory_manager = None + repl.command_registry = MagicMock() + repl.command_registry.get_model_invocable_skills.return_value = [] + + fake_pipeline = MagicMock() + fake_pipeline.sidecar_restore_result = MagicMock(ok=True, status="running", reason=None) + with ( + patch("iac_code.pipeline.create_pipeline", return_value=fake_pipeline) as cp, + patch.object( + repl_module, + "prepare_prerequisites", + side_effect=AssertionError("empty sidecar prerequisites should still win"), + ), + ): + await repl.swap_session_async("new") + + assert cp.call_args.kwargs["resume_from_sidecar"] is True + assert cp.call_args.kwargs["prerequisite_resolution"] == {} + + @pytest.mark.asyncio async def test_swap_session_resume_choice_switches_runtime_mode_to_pipeline(tmp_path): """A resumed sidecar must route subsequent chat turns back to pipeline mode.""" @@ -261,6 +1144,60 @@ async def test_swap_session_resume_choice_switches_runtime_mode_to_pipeline(tmp_ assert repl._pipeline is fake_pipeline +@pytest.mark.asyncio +async def test_ensure_pipeline_restored_for_prompt_uses_sidecar_prerequisites(tmp_path): + import yaml + + from iac_code.ui import repl as repl_module + from iac_code.ui.repl import InlineREPL + + repl, sessions_root = _make_repl_with_pipeline(tmp_path, "old", "new") + repl._pipeline = None + repl._runtime_mode = RunMode.PIPELINE + repl._get_runtime_mode = InlineREPL._get_runtime_mode.__get__(repl) + repl.ensure_pipeline_restored_for_prompt = InlineREPL.ensure_pipeline_restored_for_prompt.__get__(repl) + repl._detect_pipeline_session = MagicMock(return_value=True) + repl._provider_manager = MagicMock() + repl.tool_registry = MagicMock() + repl._memory_manager = None + repl._pipeline_memory_content_getter = MagicMock(return_value=lambda: "") + repl._refresh_pipeline_display_recorder = MagicMock() + repl.command_registry = MagicMock() + repl.command_registry.get_model_invocable_skills.return_value = [] + stored_prerequisites = {"feature_flags": {"reviewing": False}, "decisions": {}, "env_overrides": {}} + sidecar = sessions_root / "old" / "pipeline" + sidecar.mkdir(parents=True) + (sidecar / "meta.yaml").write_text( + yaml.dump( + { + "status": "running", + "current_step": "step1", + "state_machine": {}, + "updated_at": 0.0, + "prerequisites": stored_prerequisites, + } + ), + encoding="utf-8", + ) + + fake_pipeline = MagicMock() + fake_pipeline.sidecar_restore_result = MagicMock(ok=True, status="running", reason=None) + with ( + patch("iac_code.pipeline.create_pipeline", return_value=fake_pipeline) as cp, + patch.object( + repl_module, + "prepare_prerequisites", + side_effect=AssertionError("stored sidecar prerequisites should not be prepared again"), + ), + ): + restored = await repl.ensure_pipeline_restored_for_prompt() + + assert restored is True + assert cp.call_args.kwargs["resume_from_sidecar"] is True + assert cp.call_args.kwargs["prerequisite_resolution"] == stored_prerequisites + assert repl._pipeline is fake_pipeline + + @pytest.mark.asyncio async def test_swap_session_running_resume_routes_next_message_to_interrupt_judge(tmp_path): """A running sidecar restored via /resume must judge the next input.""" diff --git a/tests/ui/test_stream_accumulator.py b/tests/ui/test_stream_accumulator.py index df5bb995..09185163 100644 --- a/tests/ui/test_stream_accumulator.py +++ b/tests/ui/test_stream_accumulator.py @@ -32,6 +32,15 @@ def test_orphan_tool_result_fallback_allows_single_pending_tool_name() -> None: assert acc.tool_records["tool-a"].result == "ok" +def test_tool_use_start_records_renderer_tool() -> None: + acc = StreamAccumulator() + renderer_tool = object() + + acc.process(ToolUseStartEvent(tool_use_id="tool-a", name="infraguard_scan", renderer_tool=renderer_tool)) + + assert acc.tool_records["tool-a"].renderer_tool is renderer_tool + + def test_mcp_progress_updates_matching_tool_record() -> None: acc = StreamAccumulator() acc.process(ToolUseStartEvent(tool_use_id="tool-a", name="mcp__live__echo")) diff --git a/tests/ui/test_transcript_view.py b/tests/ui/test_transcript_view.py index 23e4894b..b1cbd4e6 100644 --- a/tests/ui/test_transcript_view.py +++ b/tests/ui/test_transcript_view.py @@ -7,8 +7,11 @@ from rich.console import Console from rich.text import Text +from iac_code.pipeline.selling.tools import InfraGuardScanTool +from iac_code.tools.base import ToolRegistry +from iac_code.tools.read_file import ReadFileTool from iac_code.ui.core.key_event import KeyEvent -from iac_code.ui.renderer import RenderedTurn, _Segment, _ToolCallRecord +from iac_code.ui.renderer import RenderedTurn, Renderer, _Segment, _ToolCallRecord from iac_code.ui.transcript_view import TranscriptView @@ -33,9 +36,17 @@ def make_renderer(height: int = 8): _render_text_block=lambda text, continuation=False: [Text(f"TXT:{text}")], _render_tool_header=lambda rec: Text("HEAD\n CHILD"), _render_tool_result=lambda rec: Text("RESULT"), + _has_verbose_content=lambda rec: False, ) +def make_real_renderer(height: int = 8) -> Renderer: + registry = ToolRegistry() + registry.register(InfraGuardScanTool()) + registry.register(ReadFileTool()) + return Renderer(make_console(height=height), registry, status_callback=lambda: "ready") + + class TestTranscriptView: def test_run_returns_immediately_without_lines(self, monkeypatch): view = TranscriptView(make_renderer()) @@ -118,6 +129,87 @@ def test_render_tool_inserts_prompt_between_header_and_children(self): result_pos = output.index("RESULT") assert head_pos < prompt_pos < child_pos < result_pos + def test_render_tool_uses_verbose_result_for_pipeline_renderer_tool(self): + class TranscriptVerboseTool: + render_verbose_result_in_transcript = True + + renderer = make_renderer() + renderer._render_tool_result = lambda rec: Text("VERBOSE" if renderer._verbose else "COMPACT") + renderer._has_verbose_content = lambda rec: True + view = TranscriptView(renderer) + rec = _ToolCallRecord( + tool_name="custom_pipeline_tool", + tool_input={}, + done=True, + result="raw", + renderer_tool=TranscriptVerboseTool(), + ) + + view._render_tool(rec) + + output = renderer.console.file.getvalue() + assert "VERBOSE" in output + assert "COMPACT" not in output + + def test_render_tool_keeps_compact_result_without_pipeline_renderer_tool(self): + renderer = make_renderer() + renderer._render_tool_result = lambda rec: Text("VERBOSE" if renderer._verbose else "COMPACT") + renderer._has_verbose_content = lambda rec: True + view = TranscriptView(renderer) + rec = _ToolCallRecord(tool_name="read_file", tool_input={}, done=True, result="raw") + + view._render_tool(rec) + + output = renderer.console.file.getvalue() + assert "COMPACT" in output + assert "VERBOSE" not in output + + def test_render_tool_keeps_read_file_compact_even_with_renderer_tool(self): + renderer = make_real_renderer() + view = TranscriptView(renderer) + rec = _ToolCallRecord( + tool_name="read_file", + tool_input={"path": "config.yaml"}, + done=True, + result=" 1\tsecret: value\n 2\tanother: value\n", + renderer_tool=ReadFileTool(), + ) + + view._render_tool(rec) + + output = renderer.console.file.getvalue() + assert "Read " in output + assert " lines" in output + assert "secret: value" not in output + + def test_render_tool_uses_verbose_result_for_infraguard_scan(self): + renderer = make_real_renderer() + view = TranscriptView(renderer) + result = { + "passed": True, + "blocking_findings": 0, + "findings": [{"severity": "low", "rule_id": "rule:demo", "line": 5, "reason": "reason text"}], + "summary": {"severity_counts": {"low": 1}}, + "file_path": "templates/demo.yml", + "selected_aspects": ["best_practice"], + "expanded_policies": ["pack:demo"], + } + rec = _ToolCallRecord( + tool_name="infraguard_scan", + tool_input={"file_path": "templates/demo.yml"}, + done=True, + result=__import__("json").dumps(result), + renderer_tool=InfraGuardScanTool(), + ) + + view._render_tool(rec) + + output = renderer.console.file.getvalue() + assert "Status: passed" in output + assert "Findings:" in output + assert "low · rule:demo · line 5" in output + assert '"findings"' not in output + def test_draw_crops_oldest_lines_and_renders_footer(self): renderer = make_renderer(height=6) view = TranscriptView(renderer) From 7392b17a76b0d4b8a1fabcf1900806bcc66c66ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Tue, 7 Jul 2026 14:46:22 +0800 Subject: [PATCH 2/2] fix: handle infraguard scan timeouts on python 3.10 --- src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py | 4 ++-- tests/pipeline/selling/test_infraguard_scan_tool.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py b/src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py index b68b3e31..15a3f047 100644 --- a/src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py +++ b/src/iac_code/pipeline/selling/tools/infraguard_scan_tool.py @@ -179,7 +179,7 @@ async def _run_infraguard_command( ) try: stdout_bytes, stderr_bytes = await asyncio.wait_for(process.communicate(), timeout=timeout_seconds) - except TimeoutError as exc: + except asyncio.TimeoutError as exc: stdout_bytes, stderr_bytes = await _terminate_infraguard_process(process) raise subprocess.TimeoutExpired( command, @@ -211,7 +211,7 @@ async def _terminate_infraguard_process(process: asyncio.subprocess.Process) -> _terminate_infraguard_process_tree(process) try: return await asyncio.wait_for(process.communicate(), timeout=3) - except TimeoutError: + except asyncio.TimeoutError: _terminate_infraguard_process_tree(process, force=True) with suppress(Exception): return await asyncio.wait_for(process.communicate(), timeout=3) diff --git a/tests/pipeline/selling/test_infraguard_scan_tool.py b/tests/pipeline/selling/test_infraguard_scan_tool.py index b5a2e6cd..2951e58c 100644 --- a/tests/pipeline/selling/test_infraguard_scan_tool.py +++ b/tests/pipeline/selling/test_infraguard_scan_tool.py @@ -83,7 +83,7 @@ async def test_run_infraguard_command_timeout_terminates_descendant_process(tmp_ await scan_module._run_infraguard_command( [sys.executable, "-c", parent_code, str(ticks_path), str(pid_path), child_code], cwd=str(tmp_path), - timeout_seconds=0.5, + timeout_seconds=2.0, ) size_after_timeout = ticks_path.stat().st_size