diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67547d4..2ddf03b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: - name: Lint run: make lint - - name: Check typed extension contracts + - name: Check strict typing run: make typecheck test: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 796a620..652955c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -37,7 +37,7 @@ make setup ```bash make test # pytest suite with branch coverage -make typecheck # typed extension contracts + external consumer fixture +make typecheck # strict type checking for package and fixtures make lint # project-env ruff check src tests scripts make format # project-env ruff import fixes + format src tests scripts make format-check # project-env ruff format --check src tests scripts @@ -273,8 +273,8 @@ exists on TestPyPI. - Bug fixes must include regression tests. - Keep tests deterministic and filesystem-local. - Integration implementations must include contract tests under `tests/integration/architecture/` and adapter tests under `tests/integration/adapters/`. -- Public extension contracts must pass `make typecheck`; the package job also - type-checks the consumer fixture against the built wheel. +- Production code and typing fixtures must pass strict mypy via `make typecheck`; + CI also validates the built wheel's public typing. - Tests enforce branch coverage. ## Mock Invoker Local Validation diff --git a/Makefile b/Makefile index 681a6af..ef7fcae 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ help: 'Development:' \ ' setup Install editable dev environment' \ ' test Run pytest' \ - ' typecheck Check typed extension contracts and a public consumer' \ + ' typecheck Check strict typing for the package and fixtures' \ ' lint Run ruff checks' \ ' format Run ruff import fixes and formatter' \ ' format-check Check formatting' \ @@ -96,27 +96,10 @@ uninstall: test: $(RUN_PYTEST) -p pytest_cov --cov=crewplane --cov-branch --cov-report=term-missing:skip-covered --cov-fail-under=$(COVERAGE_FLOOR) -# Strict mypy adoption currently covers public extension contracts and their -# direct consumers; the rest of the package is not yet strict-mypy clean. +# Keep the full production package and repository typecheck fixtures under +# strict mypy coverage. typecheck: - $(RUN_MYPY) \ - src/crewplane/architecture/contracts \ - src/crewplane/architecture/ports \ - src/crewplane/bootstrap/container.py \ - src/crewplane/adapters/ui \ - src/crewplane/adapters/invokers/mock_invoker/context.py \ - src/crewplane/observability/observer.py \ - src/crewplane/observability/runtime.py \ - src/crewplane/observability/tmux/selection.py \ - src/crewplane/observability/tmux/snapshot_types.py \ - src/crewplane/observability/tmux/compact.py \ - src/crewplane/observability/tmux/rendering.py \ - src/crewplane/observability/tmux/refresh.py \ - src/crewplane/observability/tmux/selected_invocation.py \ - src/crewplane/observability/run_summary/builder.py \ - src/crewplane/observability/run_summary/logger.py \ - tests/typecheck/public_observer_consumer.py \ - tests/typecheck/public_artifacts_consumer.py + $(RUN_MYPY) src/crewplane tests/typecheck lint: $(RUN_RUFF) check src tests scripts diff --git a/pyproject.toml b/pyproject.toml index 94da277..7db60ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dev = [ "pytest-cov>=7.0", "ruff>=0.6", "twine>=6.0", + "types-PyYAML>=6.0.12", ] stress = [ "pytest-randomly>=4.0.1", diff --git a/src/crewplane/adapters/artifacts/filesystem.py b/src/crewplane/adapters/artifacts/filesystem.py index 63f5dae..d0d9a54 100644 --- a/src/crewplane/adapters/artifacts/filesystem.py +++ b/src/crewplane/adapters/artifacts/filesystem.py @@ -1,7 +1,6 @@ from __future__ import annotations from pathlib import Path -from typing import cast from crewplane.architecture.contracts import ( CanonicalIntegrationConfig, @@ -66,14 +65,11 @@ def create_store( parsed_options = _parse_options(options) - return cast( - ArtifactStorePort, - OutputManager( - workflow_name, - base_dir=state_dir, - template_base_dir=project_root, - log_cli_output=parsed_options.log_cli_output, - ), + return OutputManager( + workflow_name, + base_dir=state_dir, + template_base_dir=project_root, + log_cli_output=parsed_options.log_cli_output, ) def create_terminal_history_reader( diff --git a/src/crewplane/adapters/artifacts/terminal_history.py b/src/crewplane/adapters/artifacts/terminal_history.py index f9a66fe..07332de 100644 --- a/src/crewplane/adapters/artifacts/terminal_history.py +++ b/src/crewplane/adapters/artifacts/terminal_history.py @@ -158,8 +158,8 @@ def _read_result_bytes(self, result_path: Path) -> TerminalHistoryRead: payload=payload, ) + @staticmethod def _matched_error( - self, message: str, path: Path | None = None, ) -> TerminalHistoryRead: diff --git a/src/crewplane/adapters/invokers/cli_invoker/claude_json.py b/src/crewplane/adapters/invokers/cli_invoker/claude_json.py index 02e82be..47eddc8 100644 --- a/src/crewplane/adapters/invokers/cli_invoker/claude_json.py +++ b/src/crewplane/adapters/invokers/cli_invoker/claude_json.py @@ -44,10 +44,6 @@ def extract_claude_output( """Extract Claude's result string into an owned temporary output file.""" extraction = _extract_claude_document( result, - use_stderr_fallback=True, - capture_result=True, - parse_result=True, - parse_model_usage=False, max_captured_usage_bytes=max_captured_usage_bytes, ) if extraction.error is not None: @@ -78,27 +74,23 @@ class ClaudeJsonDocument: def _extract_claude_document( result: CommandResult, - use_stderr_fallback: bool, - capture_result: bool, - parse_result: bool, - parse_model_usage: bool, max_captured_usage_bytes: int, ) -> ClaudeJsonDocument: document = _parse_claude_source( stdout_source(result), - capture_result=capture_result, - parse_result=parse_result, - parse_model_usage=parse_model_usage, + capture_result=True, + parse_result=True, + parse_model_usage=False, max_captured_usage_bytes=max_captured_usage_bytes, ) - if use_stderr_fallback and document.error is None and document.result_path is None: + if document.error is None and document.result_path is None: stderr_source = stream_source(result.stderr_text, result.stderr_path) if stderr_source is not None: document = _parse_claude_source( stderr_source, - capture_result=capture_result, - parse_result=parse_result, - parse_model_usage=parse_model_usage, + capture_result=True, + parse_result=True, + parse_model_usage=False, max_captured_usage_bytes=max_captured_usage_bytes, ) return document diff --git a/src/crewplane/adapters/invokers/mock.py b/src/crewplane/adapters/invokers/mock.py index f696edd..1694bf9 100644 --- a/src/crewplane/adapters/invokers/mock.py +++ b/src/crewplane/adapters/invokers/mock.py @@ -17,8 +17,8 @@ class MockInvokerAdapter: """Create deterministic mock invokers for local orchestration runs.""" + @staticmethod def canonicalize_options( - self, implementation: str, resolved_identity: str, options: JsonObject | None = None, @@ -44,8 +44,8 @@ def canonicalize_options( ).as_dict(), ) + @staticmethod def create_invoker( - self, config: Config, options: JsonObject | None = None, ) -> AgentInvoker: diff --git a/src/crewplane/architecture/contracts/__init__.py b/src/crewplane/architecture/contracts/__init__.py index 6a64c6d..6d45988 100644 --- a/src/crewplane/architecture/contracts/__init__.py +++ b/src/crewplane/architecture/contracts/__init__.py @@ -72,6 +72,7 @@ InvocationCostConfidence, InvocationDiagnostic, InvocationDiagnosticSink, + InvocationEventFields, InvocationLogLevel, InvocationPlan, InvocationProcessEvent, @@ -159,6 +160,7 @@ "InvocationContext", "InvocationDiagnostic", "InvocationDiagnosticSink", + "InvocationEventFields", "InvocationLogLevel", "InvocationPlan", "InvocationProcessEvent", diff --git a/src/crewplane/architecture/contracts/integration.py b/src/crewplane/architecture/contracts/integration.py index 24737e0..8474451 100644 --- a/src/crewplane/architecture/contracts/integration.py +++ b/src/crewplane/architecture/contracts/integration.py @@ -140,12 +140,11 @@ def sensitive_integration_option_keys( def redacted_integration_option_value( - value: JsonValue = None, + value: JsonValue = None, # noqa: ARG001 - Raw secrets must not affect redaction. fingerprint: str | None = None, value_handle: str | None = None, ) -> JsonObject: """Build trusted redaction metadata without retaining the raw value.""" - del value redacted: JsonObject = {"redacted": True} if fingerprint is not None: redacted["fingerprint"] = fingerprint diff --git a/src/crewplane/architecture/contracts/integration_secrets.py b/src/crewplane/architecture/contracts/integration_secrets.py index fd6c6c7..dcedecb 100644 --- a/src/crewplane/architecture/contracts/integration_secrets.py +++ b/src/crewplane/architecture/contracts/integration_secrets.py @@ -114,7 +114,8 @@ def _should_redact( isinstance(value, (dict, list)) and self._has_explicit_descendant(pointer) ) - def _is_sensitive_name(self, path: tuple[JsonPathSegment, ...]) -> bool: + @staticmethod + def _is_sensitive_name(path: tuple[JsonPathSegment, ...]) -> bool: segment = path[-1] return ( isinstance(segment, str) diff --git a/src/crewplane/architecture/contracts/invocation.py b/src/crewplane/architecture/contracts/invocation.py index 26e27b9..70e0a92 100644 --- a/src/crewplane/architecture/contracts/invocation.py +++ b/src/crewplane/architecture/contracts/invocation.py @@ -7,7 +7,7 @@ from enum import StrEnum from pathlib import Path from types import MappingProxyType -from typing import TYPE_CHECKING, Literal, Protocol, cast +from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, cast from crewplane.core.workflow.keywords import ProviderRole @@ -127,6 +127,23 @@ def add_exact(self, additional: ProviderTokenUsage) -> ProviderTokenUsage: ) +class InvocationEventFields(TypedDict, total=False): + """Keyword fields persisted with terminal invocation events.""" + + attempt_count: int + cli_captured: bool + output_extraction_status: OutputExtractionStatus + provider_usage_status: ProviderUsageStatus + provider_usage_report_count: int | None + provider_tokens: dict[str, int | None] + visible_estimate_tokens: int | None + visible_estimate_method: str | None + visible_estimate_is_lower_bound: bool + configured_cost_usd: float | None + invocation_cost_confidence: InvocationCostConfidence + usage_parse_error: str | None + + @dataclass(frozen=True) class InvocationUsage: """Normalized usage and cost evidence for one terminal invocation.""" @@ -160,7 +177,7 @@ def __post_init__(self) -> None: MappingProxyType(dict(self.provider_tokens)), ) - def as_event_fields(self) -> JsonObject: + def as_event_fields(self) -> InvocationEventFields: """Return a JSON-compatible shallow copy for event persistence.""" return { "attempt_count": self.attempt_count, @@ -436,12 +453,10 @@ def iter_combined_lines(self) -> Iterator[str]: def cleanup_stream_files(self) -> None: """Remove persisted stream capture files for this invocation result.""" - if self.stdout_path is not None: - with suppress(OSError): - self.stdout_path.unlink(missing_ok=True) - if self.stderr_path is not None: - with suppress(OSError): - self.stderr_path.unlink(missing_ok=True) + for path in (self.stdout_path, self.stderr_path): + if path is not None: + with suppress(OSError): + path.unlink(missing_ok=True) @dataclass(frozen=True) diff --git a/src/crewplane/architecture/ports/__init__.py b/src/crewplane/architecture/ports/__init__.py index da8a088..d3ab9d0 100644 --- a/src/crewplane/architecture/ports/__init__.py +++ b/src/crewplane/architecture/ports/__init__.py @@ -4,20 +4,24 @@ ProviderProcessInvocation, ProviderProcessPublication, ProviderProcessStorePort, + RunSummaryArtifactReaderPort, TerminalHistoryRead, TerminalHistoryReaderPort, ) from .invoker import InvokerAdapterPort +from .options import IntegrationOptionsCanonicalizerPort from .runtime import RuntimeComponents, UIRuntimePlan from .ui import UIAdapterCapabilities, UIAdapterPort __all__ = [ "ArtifactAdapterPort", "ArtifactStorePort", + "IntegrationOptionsCanonicalizerPort", "InvokerAdapterPort", "ProviderProcessInvocation", "ProviderProcessPublication", "ProviderProcessStorePort", + "RunSummaryArtifactReaderPort", "TerminalHistoryRead", "TerminalHistoryReaderPort", "RuntimeComponents", diff --git a/src/crewplane/architecture/ports/artifacts.py b/src/crewplane/architecture/ports/artifacts.py index e4f4bc2..0c05f71 100644 --- a/src/crewplane/architecture/ports/artifacts.py +++ b/src/crewplane/architecture/ports/artifacts.py @@ -5,7 +5,6 @@ from typing import Protocol, runtime_checkable from crewplane.architecture.contracts import ( - CanonicalIntegrationConfig, InvocationProcessEvent, JsonObject, NodeArtifactRequest, @@ -15,6 +14,8 @@ from crewplane.core.preflight.models import PreflightExecutionPlan from crewplane.core.workflow.keywords import ProviderRole +from .options import IntegrationOptionsCanonicalizerPort + @dataclass(frozen=True) class TerminalHistoryRead: @@ -118,13 +119,33 @@ def write_provider_process_event( class ArtifactStorePort(Protocol): """Runtime-facing artifact store used during a single workflow run.""" - run_id: str - run_key_name: str - task_name: str - stages_dir: Path - results_dir: Path - logs_dir: Path - log_cli_output: bool + @property + def run_id(self) -> str: + """Return the unique run identifier.""" + + @property + def run_key_name(self) -> str: + """Return the filesystem-safe run key.""" + + @property + def task_name(self) -> str: + """Return the normalized workflow name.""" + + @property + def stages_dir(self) -> Path: + """Return the run's execution-stage directory.""" + + @property + def results_dir(self) -> Path: + """Return the run's execution-result directory.""" + + @property + def logs_dir(self) -> Path: + """Return the run-level log directory.""" + + @property + def log_cli_output(self) -> bool: + """Return whether provider output is captured in invocation logs.""" def create_node_dir(self, request: NodeArtifactRequest) -> Path: """Create the stage directory at the compiled locator.""" @@ -246,16 +267,31 @@ def write_workspace_export( """Persist a run-level workspace branch export record.""" -class ArtifactAdapterPort(Protocol): - """Factory contract for artifact storage integrations.""" +class RunSummaryArtifactReaderPort(Protocol): + """Read-only artifact surface required to construct a run summary.""" + + @property + def stages_dir(self) -> Path: + """Return the run's execution-stage directory.""" - def canonicalize_options( + def get_run_event_log_path(self) -> Path: + """Return the persistent execution-event log path.""" + + def get_run_summary_path(self) -> Path: + """Return the persistent run-summary path.""" + + def get_node_artifact_request( self, - implementation: str, - resolved_identity: str, - options: JsonObject | None = None, - ) -> CanonicalIntegrationConfig: - """Validate and canonicalize artifact options without side effects.""" + node_id: str, + ) -> NodeArtifactRequest | None: + """Return the compiled request for an observed node when available.""" + + def get_node_output_path(self, request: NodeArtifactRequest) -> Path: + """Resolve the compiled output locator for a node.""" + + +class ArtifactAdapterPort(IntegrationOptionsCanonicalizerPort, Protocol): + """Factory contract for artifact storage integrations.""" def create_store( self, diff --git a/src/crewplane/architecture/ports/invoker.py b/src/crewplane/architecture/ports/invoker.py index ec3db90..b78e51f 100644 --- a/src/crewplane/architecture/ports/invoker.py +++ b/src/crewplane/architecture/ports/invoker.py @@ -2,24 +2,14 @@ from typing import Protocol -from crewplane.architecture.contracts import ( - AgentInvoker, - CanonicalIntegrationConfig, - JsonObject, -) +from crewplane.architecture.contracts import AgentInvoker, JsonObject from crewplane.core.config import Config +from .options import IntegrationOptionsCanonicalizerPort -class InvokerAdapterPort(Protocol): - """Factory contract for provider invocation integrations.""" - def canonicalize_options( - self, - implementation: str, - resolved_identity: str, - options: JsonObject | None = None, - ) -> CanonicalIntegrationConfig: - """Validate and canonicalize invoker options without side effects.""" +class InvokerAdapterPort(IntegrationOptionsCanonicalizerPort, Protocol): + """Factory contract for provider invocation integrations.""" def create_invoker( self, diff --git a/src/crewplane/architecture/ports/options.py b/src/crewplane/architecture/ports/options.py new file mode 100644 index 0000000..ab43d99 --- /dev/null +++ b/src/crewplane/architecture/ports/options.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Protocol + +from crewplane.architecture.contracts import CanonicalIntegrationConfig, JsonObject + + +class IntegrationOptionsCanonicalizerPort(Protocol): + """Canonicalize adapter options without creating runtime resources.""" + + def canonicalize_options( + self, + implementation: str, + resolved_identity: str, + options: JsonObject | None = None, + ) -> CanonicalIntegrationConfig: + """Validate options and return their deterministic integration contract.""" diff --git a/src/crewplane/architecture/ports/ui.py b/src/crewplane/architecture/ports/ui.py index 6266c97..9663607 100644 --- a/src/crewplane/architecture/ports/ui.py +++ b/src/crewplane/architecture/ports/ui.py @@ -6,14 +6,12 @@ from rich.console import Console -from crewplane.architecture.contracts import ( - CanonicalIntegrationConfig, - JsonObject, - WorkflowTopology, -) +from crewplane.architecture.contracts import JsonObject, WorkflowTopology from crewplane.architecture.ports.runtime import UIRuntimePlan from crewplane.core.config import Config +from .options import IntegrationOptionsCanonicalizerPort + @dataclass(frozen=True) class UIAdapterCapabilities: @@ -23,19 +21,11 @@ class UIAdapterCapabilities: accepts_which_override: bool = False -class UIAdapterPort(Protocol): +class UIAdapterPort(IntegrationOptionsCanonicalizerPort, Protocol): """Factory contract for optional live runtime integrations.""" capabilities: UIAdapterCapabilities - def canonicalize_options( - self, - implementation: str, - resolved_identity: str, - options: JsonObject | None = None, - ) -> CanonicalIntegrationConfig: - """Validate and canonicalize UI options without side effects.""" - def create_runtime( self, config: Config, diff --git a/src/crewplane/architecture/safe_files.py b/src/crewplane/architecture/safe_files.py index fe9f632..ee1c081 100644 --- a/src/crewplane/architecture/safe_files.py +++ b/src/crewplane/architecture/safe_files.py @@ -54,7 +54,7 @@ def contained_regular_file(root: Path, relative_path: str) -> Path | None: parts = _relative_path_parts_optional(relative_path) if not parts: return None - if _has_symlink_component(root): + if path_has_symlink_component(root): return None candidate = _walk_without_symlink(root, parts) if candidate is None: @@ -154,7 +154,7 @@ def _walk_without_symlink(root: Path, parts: tuple[str, ...]) -> Path | None: candidate = root for part in parts: candidate = candidate / part - if _path_is_symlink(candidate): + if path_is_symlink(candidate): return None return candidate @@ -318,17 +318,21 @@ def _ensure_directory_component(path: Path) -> None: raise ValueError(f"Directory component must be a real directory: {path}") -def _has_symlink_component(path: Path) -> bool: +def path_has_symlink_component(path: Path) -> bool: + """Return whether any existing component in ``path`` is a symlink.""" + current = Path(path.anchor) if path.is_absolute() else Path() parts = path.parts[1:] if path.is_absolute() else path.parts for part in parts: current = current / part - if _path_is_symlink(current): + if path_is_symlink(current): return True return False -def _path_is_symlink(path: Path) -> bool: +def path_is_symlink(path: Path) -> bool: + """Return whether ``path`` itself is a symlink without following it.""" + try: return stat.S_ISLNK(path.lstat().st_mode) except PermissionError: diff --git a/src/crewplane/artifacts/generated_files/catalog.py b/src/crewplane/artifacts/generated_files/catalog.py index ed12637..5211556 100644 --- a/src/crewplane/artifacts/generated_files/catalog.py +++ b/src/crewplane/artifacts/generated_files/catalog.py @@ -26,6 +26,7 @@ GeneratedFileRejectionLog, GeneratedFileSnapshotCandidate, GeneratedFileSnapshotPolicy, + GeneratedFileSnapshotSelection, generated_file_rejection_metadata, generated_file_snapshot_candidate_metadata, select_generated_file_snapshot_candidates, @@ -149,31 +150,13 @@ def snapshot_generated_file_workspace( content = output_file.read_text(encoding="utf-8") if output_file.is_file() else "" selected_snapshot_root = snapshot_root or generated_file_source_root(output_file) resolved_workspace_root = workspace_root.resolve(strict=True) - detector = GeneratedFileReferenceDetector(resolved_workspace_root) - explicit_files = detector.detect_explicit_section(content) - explicit_labels = { - path.relative_to(resolved_workspace_root).as_posix() for path in explicit_files - } - generated_files = _ordered_generated_files_for_content( + selection = _select_snapshot_candidates( content, - detector, resolved_workspace_root, + changed_paths, candidate_files, explicit_claims_only, ) - selection = select_generated_file_snapshot_candidates( - generated_files, - GeneratedFileSnapshotPolicy( - resolved_workspace_root=resolved_workspace_root, - changed_paths=changed_paths, - explicit_labels=explicit_labels, - baseline_supplied=candidate_files is not None, - file_count_limit=MAX_GENERATED_FILE_SNAPSHOT_FILES, - per_file_size_limit=MAX_GENERATED_FILE_SNAPSHOT_BYTES, - total_size_limit=MAX_GENERATED_FILE_SNAPSHOT_TOTAL_BYTES, - rejection_detail_limit=MAX_GENERATED_FILE_SNAPSHOT_REJECTION_DETAILS, - ), - ) _replace_generated_file_source_root(selected_snapshot_root) source_metadata_signature = _write_generated_file_source_metadata( selected_snapshot_root, @@ -186,29 +169,14 @@ def snapshot_generated_file_workspace( ) copied_candidates: list[GeneratedFileSnapshotCandidate] = [] for candidate in selection.candidates: - target = selected_snapshot_root.joinpath(*candidate.relative_path.parts) - _ensure_contained_directory( + if _publish_snapshot_candidate( + candidate, selected_snapshot_root, - candidate.relative_path.parent, - ) - try: - target_signature = copy_generated_file_snapshot_candidate( - candidate, - target, - resolved_workspace_root, - ) - except (OSError, RuntimeError) as exc: - selection.rejections.record( - generated_file_rejection_metadata( - candidate, - reason="copy_failed", - error=str(exc), - ) - ) - continue - copied_candidates.append(candidate) - if on_file_published is not None: - on_file_published(target, target_signature) + resolved_workspace_root, + selection.rejections, + on_file_published, + ): + copied_candidates.append(candidate) snapshot_metadata_signature = _write_generated_file_snapshot_metadata( selected_snapshot_root, [ @@ -225,6 +193,69 @@ def snapshot_generated_file_workspace( return selected_snapshot_root +def _select_snapshot_candidates( + content: str, + resolved_workspace_root: Path, + changed_paths: set[str] | None, + candidate_files: Sequence[Path] | None, + explicit_claims_only: bool, +) -> GeneratedFileSnapshotSelection: + detector = GeneratedFileReferenceDetector(resolved_workspace_root) + explicit_labels = { + path.relative_to(resolved_workspace_root).as_posix() + for path in detector.detect_explicit_section(content) + } + generated_files = _ordered_generated_files_for_content( + content, + detector, + resolved_workspace_root, + candidate_files, + explicit_claims_only, + ) + return select_generated_file_snapshot_candidates( + generated_files, + GeneratedFileSnapshotPolicy( + resolved_workspace_root=resolved_workspace_root, + changed_paths=changed_paths, + explicit_labels=explicit_labels, + baseline_supplied=candidate_files is not None, + file_count_limit=MAX_GENERATED_FILE_SNAPSHOT_FILES, + per_file_size_limit=MAX_GENERATED_FILE_SNAPSHOT_BYTES, + total_size_limit=MAX_GENERATED_FILE_SNAPSHOT_TOTAL_BYTES, + rejection_detail_limit=MAX_GENERATED_FILE_SNAPSHOT_REJECTION_DETAILS, + ), + ) + + +def _publish_snapshot_candidate( + candidate: GeneratedFileSnapshotCandidate, + snapshot_root: Path, + resolved_workspace_root: Path, + rejections: GeneratedFileRejectionLog, + on_file_published: Callable[[Path, tuple[int, str]], None] | None, +) -> bool: + target = snapshot_root.joinpath(*candidate.relative_path.parts) + _ensure_contained_directory(snapshot_root, candidate.relative_path.parent) + try: + target_signature = copy_generated_file_snapshot_candidate( + candidate, + target, + resolved_workspace_root, + ) + except (OSError, RuntimeError) as exc: + rejections.record( + generated_file_rejection_metadata( + candidate, + reason="copy_failed", + error=str(exc), + ) + ) + return False + if on_file_published is not None: + on_file_published(target, target_signature) + return True + + def _ordered_generated_files_for_content( content: str, detector: GeneratedFileReferenceDetector, diff --git a/src/crewplane/artifacts/locks/__init__.py b/src/crewplane/artifacts/locks/__init__.py index bc74c34..c2f4a48 100644 --- a/src/crewplane/artifacts/locks/__init__.py +++ b/src/crewplane/artifacts/locks/__init__.py @@ -7,6 +7,7 @@ from datetime import datetime from pathlib import Path from time import monotonic, sleep +from typing import Literal from pydantic import BaseModel, ConfigDict, ValidationError, field_validator @@ -26,6 +27,7 @@ from .provider_processes import ensure_no_live_provider_processes LOCK_OWNER_FILENAME = "owner.json" +type LockActivity = Literal["live", "stale", "none", "unverifiable"] class ResumeLockError(RuntimeError): @@ -36,7 +38,7 @@ def run_lock_activity( state_dir: Path, run_key_name: str, process_inspector: ProcessInspector | None = None, -) -> str: +) -> LockActivity: """Return ``live``, ``stale``, ``none``, or ``unverifiable`` for a run lock.""" inspector = process_inspector or ProcessInspector() diff --git a/src/crewplane/artifacts/locks/manifest.py b/src/crewplane/artifacts/locks/manifest.py index e0e3927..7eba9dc 100644 --- a/src/crewplane/artifacts/locks/manifest.py +++ b/src/crewplane/artifacts/locks/manifest.py @@ -16,7 +16,11 @@ EventType, WorkflowEventType, ) -from crewplane.architecture.safe_files import contained_regular_file +from crewplane.architecture.safe_files import ( + contained_regular_file, + path_has_symlink_component, + path_is_symlink, +) from crewplane.core.execution_state import ( RUN_STATUS_RUNNING, RunManifest, @@ -396,7 +400,7 @@ def ensure_owner_path_contained(root: Path, candidate: Path) -> None: def ensure_no_symlink_manifest_components(root: Path, candidate: Path) -> None: - if has_symlink_component(root): + if path_has_symlink_component(root): raise LockManifestError("Stale run manifest path contains a symlink.") try: relative = candidate.relative_to(root) @@ -409,22 +413,3 @@ def ensure_no_symlink_manifest_components(root: Path, candidate: Path) -> None: current = current / part if path_is_symlink(current): raise LockManifestError("Stale run manifest path contains a symlink.") - - -def has_symlink_component(path: Path) -> bool: - current = Path(path.anchor) if path.is_absolute() else Path() - parts = path.parts[1:] if path.is_absolute() else path.parts - for part in parts: - current = current / part - if path_is_symlink(current): - return True - return False - - -def path_is_symlink(path: Path) -> bool: - try: - return stat.S_ISLNK(path.lstat().st_mode) - except PermissionError: - raise - except OSError: - return False diff --git a/src/crewplane/artifacts/results/findings.py b/src/crewplane/artifacts/results/findings.py index 1326e40..e04e2ea 100644 --- a/src/crewplane/artifacts/results/findings.py +++ b/src/crewplane/artifacts/results/findings.py @@ -10,7 +10,7 @@ from ..failure_artifacts import is_synthetic_invocation_failure -FINDINGS_BLOCK_PATTERN = re.compile( +FINDINGS_BLOCK_PATTERN: re.Pattern[str] = re.compile( r"\s*(.*?)\s*", re.DOTALL, ) @@ -70,7 +70,7 @@ def extract_findings_content(raw_output: str, output_file: Path) -> str: "Expected exactly one findings block in " f"'{output_file}'. Use ... ." ) - findings_content = matches[0].strip() + findings_content = str(matches[0]).strip() if findings_content: return findings_content raise FindingsExtractionError( diff --git a/src/crewplane/artifacts/results/stage_document.py b/src/crewplane/artifacts/results/stage_document.py index 4139705..d60dd37 100644 --- a/src/crewplane/artifacts/results/stage_document.py +++ b/src/crewplane/artifacts/results/stage_document.py @@ -6,11 +6,13 @@ from crewplane.artifacts.atomic import atomic_write_text from ..generated_files.catalog import ( - GeneratedFileLink, - GeneratedFileReferenceDetector, build_generated_file_links_section, build_generated_files_section, ) +from ..generated_files.detection import ( + GeneratedFileLink, + GeneratedFileReferenceDetector, +) def write_stage_result_file( diff --git a/src/crewplane/artifacts/results/stage_outputs.py b/src/crewplane/artifacts/results/stage_outputs.py index 3c6f165..23935fa 100644 --- a/src/crewplane/artifacts/results/stage_outputs.py +++ b/src/crewplane/artifacts/results/stage_outputs.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from pathlib import Path -from ..generated_files.catalog import GeneratedFileLink +from ..generated_files.detection import GeneratedFileLink @dataclass diff --git a/src/crewplane/artifacts/results/writer.py b/src/crewplane/artifacts/results/writer.py index 8012d88..2a480e1 100644 --- a/src/crewplane/artifacts/results/writer.py +++ b/src/crewplane/artifacts/results/writer.py @@ -10,9 +10,7 @@ from crewplane.artifacts.atomic import atomic_write_text from crewplane.core.workflow.keywords import ProviderRole -from ..generated_files.catalog import ( - GeneratedFileReferenceDetector, -) +from ..generated_files.detection import GeneratedFileReferenceDetector from .aggregation import aggregate_stage_outputs from .findings import ( build_findings_document, diff --git a/src/crewplane/artifacts/resume/hydration.py b/src/crewplane/artifacts/resume/hydration.py index bb81748..2b78e06 100644 --- a/src/crewplane/artifacts/resume/hydration.py +++ b/src/crewplane/artifacts/resume/hydration.py @@ -8,6 +8,7 @@ from crewplane.architecture.contracts import JsonObject, JsonValue, NodeArtifactRequest from crewplane.architecture.ports import ArtifactStorePort +from crewplane.architecture.safe_files import contained_regular_file from crewplane.core.execution_state import ( RUN_STATE_SCHEMA_VERSION, ArtifactDescriptor, @@ -27,7 +28,6 @@ from .generated_files import copy_generated_file_descriptors from .validation import ( ValidatedResumeFrontier, - contained_regular_file, required_resume_artifact_paths, ) from .verified_copy import VerifiedCopyLabels, copy_verified_artifact diff --git a/src/crewplane/artifacts/resume/validation.py b/src/crewplane/artifacts/resume/validation.py index a5d7dd7..68b6181 100644 --- a/src/crewplane/artifacts/resume/validation.py +++ b/src/crewplane/artifacts/resume/validation.py @@ -12,6 +12,7 @@ from crewplane.core.execution_state import ( RUN_STATUS_SUCCEEDED, ArtifactDescriptor, + ArtifactKind, NodeState, ) from crewplane.core.file_hashing import sha256_file @@ -262,8 +263,8 @@ def _node_state_matches_context( def required_resume_artifact_paths( plan: PreflightExecutionPlan, node: PreflightExecutionNode, -) -> dict[str, str]: - required = {"output": node.artifact_contract.output_path} +) -> dict[ArtifactKind, str]: + required: dict[ArtifactKind, str] = {"output": node.artifact_contract.output_path} findings_required = node.findings or any( edge.source_node == node.id and edge.artifact_name in _FINDINGS_KEYS for edge in plan.dependency_graph @@ -295,7 +296,7 @@ def _descriptor_matches_file( def _dependents_by_node( dependencies: dict[str, set[str]], ) -> dict[str, set[str]]: - dependents = {node_id: set() for node_id in dependencies} + dependents: dict[str, set[str]] = {node_id: set() for node_id in dependencies} for node_id, node_dependencies in dependencies.items(): for dependency in node_dependencies: dependents.setdefault(dependency, set()).add(node_id) @@ -303,7 +304,7 @@ def _dependents_by_node( def _dependencies_by_node(plan: PreflightExecutionPlan) -> dict[str, set[str]]: - dependencies = {node.id: set() for node in plan.nodes} + dependencies: dict[str, set[str]] = {node.id: set() for node in plan.nodes} for edge in plan.dependency_graph: if edge.target_node in dependencies: dependencies[edge.target_node].add(edge.source_node) diff --git a/src/crewplane/artifacts/run_history.py b/src/crewplane/artifacts/run_history.py index a15e804..33630c2 100644 --- a/src/crewplane/artifacts/run_history.py +++ b/src/crewplane/artifacts/run_history.py @@ -8,6 +8,10 @@ from pydantic import ValidationError +from crewplane.architecture.safe_files import ( + path_has_symlink_component, + path_is_symlink, +) from crewplane.core.execution_state import RunManifest from .naming import validate_run_key_name @@ -167,7 +171,7 @@ def _ensure_contained_run_path(root: Path, candidate: Path) -> None: def _ensure_no_symlink_metadata_components(root: Path, candidate: Path) -> None: - if _has_symlink_component(root): + if path_has_symlink_component(root): raise RunHistoryError("Run history metadata path contains a symlink.") try: relative = candidate.relative_to(root) @@ -178,29 +182,10 @@ def _ensure_no_symlink_metadata_components(root: Path, candidate: Path) -> None: current = root for part in relative.parts: current = current / part - if _path_is_symlink(current): + if path_is_symlink(current): raise RunHistoryError("Run history metadata path contains a symlink.") -def _has_symlink_component(path: Path) -> bool: - current = Path(path.anchor) if path.is_absolute() else Path() - parts = path.parts[1:] if path.is_absolute() else path.parts - for part in parts: - current = current / part - if _path_is_symlink(current): - return True - return False - - -def _path_is_symlink(path: Path) -> bool: - try: - return stat.S_ISLNK(path.lstat().st_mode) - except PermissionError: - raise - except OSError: - return False - - def _started_at(record: RunHistoryRecord) -> datetime: return datetime.fromisoformat(record.manifest.started_at) diff --git a/src/crewplane/artifacts/workspace/bundle_validation.py b/src/crewplane/artifacts/workspace/bundle_validation.py index 152ee68..a9e001e 100644 --- a/src/crewplane/artifacts/workspace/bundle_validation.py +++ b/src/crewplane/artifacts/workspace/bundle_validation.py @@ -2,8 +2,10 @@ import subprocess import tempfile +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path +from typing import Protocol from crewplane.core.workspace.git_policy import ( sanitized_workspace_git_environment, @@ -29,6 +31,16 @@ class WorkspaceBlobDescriptor: canonical_sha256: str +class _GitRunner(Protocol): + def __call__( + self, + repo_root: Path, + env: dict[str, str], + /, # Keep runner-specific root parameter names out of protocol matching. + *args: str, + ) -> subprocess.CompletedProcess[bytes]: ... + + def workspace_bundle_contains_result( git_top_level: str, bundle_path: Path, @@ -117,13 +129,7 @@ def workspace_blob_descriptor_matches( if bundle_path is None: return _repo_blob_descriptor_matches( repo_root, - descriptor.source_commit, - descriptor.source_tree, - descriptor.git_path, - descriptor.git_blob, - descriptor.git_file_mode, - descriptor.byte_size, - descriptor.canonical_sha256, + descriptor, ) _run_git(repo_root, "bundle", "verify", bundle_path.as_posix()) if bundle_ref is not None: @@ -142,13 +148,7 @@ def workspace_blob_descriptor_matches( return False return _bundle_blob_descriptor_matches( bundle_path, - descriptor.source_commit, - descriptor.source_tree, - descriptor.git_path, - descriptor.git_blob, - descriptor.git_file_mode, - descriptor.byte_size, - descriptor.canonical_sha256, + descriptor, object_format, ) except ( @@ -249,13 +249,7 @@ def _bundle_result_tree_matches( def _bundle_blob_descriptor_matches( bundle_path: Path, - source_commit: str, - source_tree: str, - git_path: str, - git_blob: str, - git_file_mode: str, - byte_size: int, - canonical_sha256: str, + descriptor: WorkspaceBlobDescriptor, object_format: str, ) -> bool: with tempfile.TemporaryDirectory() as temp_dir: @@ -266,99 +260,77 @@ def _bundle_blob_descriptor_matches( return _git_dir_blob_descriptor_matches( git_dir, env, - source_commit, - source_tree, - git_path, - git_blob, - git_file_mode, - byte_size, - canonical_sha256, + descriptor, ) def _repo_blob_descriptor_matches( repo_root: Path, - source_commit: str, - source_tree: str, - git_path: str, - git_blob: str, - git_file_mode: str, - byte_size: int, - canonical_sha256: str, + descriptor: WorkspaceBlobDescriptor, ) -> bool: env = _sanitized_git_env() - actual_tree = _run_git_with_env( + return _blob_descriptor_matches( repo_root, env, - "rev-parse", - f"{source_commit}^{{tree}}", - ).stdout.decode("utf-8") - if actual_tree.strip() != source_tree: - return False - entry = _tree_blob_entry( - _run_git_with_env( - repo_root, - env, - "--literal-pathspecs", - "ls-tree", - "-z", - source_commit, - "--", - git_path, - ).stdout, - git_path, - ) - if entry is None: - return False - mode, object_id = entry - return ( - mode == git_file_mode - and object_id == git_blob - and _repo_blob_size(repo_root, env, object_id) == byte_size - and _repo_blob_sha256(repo_root, env, object_id) == canonical_sha256 + descriptor, + _run_git_with_env, + _repo_blob_size, + _repo_blob_sha256, ) def _git_dir_blob_descriptor_matches( git_dir: Path, env: dict[str, str], - source_commit: str, - source_tree: str, - git_path: str, - git_blob: str, - git_file_mode: str, - byte_size: int, - canonical_sha256: str, + descriptor: WorkspaceBlobDescriptor, ) -> bool: - actual_tree = _run_git_dir( + return _blob_descriptor_matches( git_dir, env, + descriptor, + _run_git_dir, + _git_dir_blob_size, + _git_dir_blob_sha256, + ) + + +def _blob_descriptor_matches( + repo_root: Path, + env: dict[str, str], + descriptor: WorkspaceBlobDescriptor, + run_git: _GitRunner, + blob_size: Callable[[Path, dict[str, str], str], int], + blob_sha256: Callable[[Path, dict[str, str], str], str], +) -> bool: + actual_tree = run_git( + repo_root, + env, "rev-parse", - f"{source_commit}^{{tree}}", + f"{descriptor.source_commit}^{{tree}}", ).stdout.decode("utf-8") - if actual_tree.strip() != source_tree: + if actual_tree.strip() != descriptor.source_tree: return False entry = _tree_blob_entry( - _run_git_dir( - git_dir, + run_git( + repo_root, env, "--literal-pathspecs", "ls-tree", "-z", - source_commit, + descriptor.source_commit, "--", - git_path, + descriptor.git_path, ).stdout, - git_path, + descriptor.git_path, ) if entry is None: return False mode, object_id = entry return ( - mode == git_file_mode - and object_id == git_blob - and _git_dir_blob_size(git_dir, env, object_id) == byte_size - and _git_dir_blob_sha256(git_dir, env, object_id) == canonical_sha256 + mode == descriptor.git_file_mode + and object_id == descriptor.git_blob + and blob_size(repo_root, env, object_id) == descriptor.byte_size + and blob_sha256(repo_root, env, object_id) == descriptor.canonical_sha256 ) diff --git a/src/crewplane/artifacts/workspace/git_blob_hash.py b/src/crewplane/artifacts/workspace/git_blob_hash.py index 238cc6d..9540812 100644 --- a/src/crewplane/artifacts/workspace/git_blob_hash.py +++ b/src/crewplane/artifacts/workspace/git_blob_hash.py @@ -3,7 +3,9 @@ import hashlib import selectors import subprocess +from io import BufferedReader from time import monotonic +from typing import Never, cast def git_stdout_sha256( @@ -30,7 +32,7 @@ def git_stdout_sha256( selector.register(stdout, selectors.EVENT_READ) while True: wait_for_stdout(process, selector, command, deadline, timeout_seconds) - chunk = stdout.read1(1024 * 1024) + chunk = cast(BufferedReader, stdout).read1(1024 * 1024) if chunk: digest.update(chunk) continue @@ -87,7 +89,7 @@ def kill_timed_out_process( process: subprocess.Popen[bytes], command: list[str], timeout_seconds: float, -) -> None: +) -> Never: kill_unfinished_process(process) raise subprocess.TimeoutExpired(command, timeout_seconds) diff --git a/src/crewplane/artifacts/workspace/source_validation.py b/src/crewplane/artifacts/workspace/source_validation.py index 2996a4d..e0ec180 100644 --- a/src/crewplane/artifacts/workspace/source_validation.py +++ b/src/crewplane/artifacts/workspace/source_validation.py @@ -14,7 +14,6 @@ resolve_review_loop_status, task_specs_for_producers, ) -from ..results.selection import parse_audit_round, parse_task_round from ..run_history import RunHistoryRecord from .state.fields import ( int_field, @@ -24,6 +23,7 @@ mapping_value as _mapping, ) from .state.invocations import workspace_state_payloads +from .state.lineage import invocation_round_order, review_output_coordinates def workspace_invocation_source_matches( @@ -328,25 +328,26 @@ def _review_loop_canonical_payload( if resolved is None or len(resolved.canonical_executor_outputs) != 1: return None entry = resolved.canonical_executor_outputs[0] - task_id, round_num = parse_task_round(entry.output_file.stem) - if task_id != entry.task_id or round_num <= 0: + coordinates = review_output_coordinates(entry.relative_path, entry.task_id) + if coordinates is None: return None - audit_round_num = None - relative_path = entry.relative_path - path_parts = relative_path.split("/") - if len(path_parts) > 1: - parsed_audit_round = parse_audit_round(path_parts[0]) - audit_round_num = parsed_audit_round if parsed_audit_round > 0 else None exact = _lineage_payload_by_invocation( payloads, entry.task_id, - round_num, - audit_round_num, + coordinates.round_num, + coordinates.audit_round_num, ) if exact is not None: return exact - if audit_round_num is not None and audit_round_num > 1 and round_num == 1: - return _latest_lineage_payload(payloads, before=(audit_round_num, round_num)) + if ( + coordinates.audit_round_num is not None + and coordinates.audit_round_num > 1 + and coordinates.round_num == 1 + ): + return _latest_lineage_payload( + payloads, + before=(coordinates.audit_round_num, coordinates.round_num), + ) return None @@ -382,15 +383,7 @@ def _latest_lineage_payload( def _lineage_payload_order(payload: dict[str, object]) -> tuple[int, int]: - round_num = int_field(payload, "round_num") - if round_num is None: - return (-1, -1) - audit_round_num = nullable_int_field(payload, "audit_round_num") - if not audit_round_num.valid: - return (-1, -1) - if audit_round_num.value is None: - return (0, round_num) - return (audit_round_num.value, round_num) + return invocation_round_order(payload) def _lineage_payload_is_ordered_source(payload: dict[str, object]) -> bool: diff --git a/src/crewplane/artifacts/workspace/state/fields.py b/src/crewplane/artifacts/workspace/state/fields.py index a252667..0d861e0 100644 --- a/src/crewplane/artifacts/workspace/state/fields.py +++ b/src/crewplane/artifacts/workspace/state/fields.py @@ -2,6 +2,7 @@ from collections.abc import Mapping from dataclasses import dataclass +from typing import TypeGuard @dataclass(frozen=True) @@ -42,7 +43,7 @@ def bool_field_matches( return isinstance(value, bool) and value == expected -def is_hex_object(value: object) -> bool: +def is_hex_object(value: object) -> TypeGuard[str]: return ( isinstance(value, str) and len(value) in {40, 64} diff --git a/src/crewplane/artifacts/workspace/state/invocations.py b/src/crewplane/artifacts/workspace/state/invocations.py index 3b7642f..457d960 100644 --- a/src/crewplane/artifacts/workspace/state/invocations.py +++ b/src/crewplane/artifacts/workspace/state/invocations.py @@ -14,10 +14,10 @@ resolve_review_loop_status, task_specs_for_producers, ) -from ...results.selection import parse_audit_round, parse_task_round from ...run_history import RunHistoryRecord from .fields import int_field, nullable_int_field from .fields import mapping_value as _mapping +from .lineage import invocation_round_order, review_output_coordinates class WorkspaceStateStatus(StrEnum): @@ -199,19 +199,14 @@ def expected_review_status_invocation( entry: ReviewLoopStatusEntry, lineage_source_required: bool = False, ) -> ExpectedWorkspaceInvocation | None: - relative_path = Path(entry.relative_path) - task_id, round_num = parse_task_round(relative_path.stem) - if task_id != entry.task_id or round_num <= 0: + coordinates = review_output_coordinates(entry.relative_path, entry.task_id) + if coordinates is None: return None - audit_round_num = None - if len(relative_path.parts) > 1: - parsed_audit_round = parse_audit_round(relative_path.parts[0]) - audit_round_num = parsed_audit_round if parsed_audit_round > 0 else None return ExpectedWorkspaceInvocation( - task_id=task_id, + task_id=coordinates.task_id, role=entry.role, - round_num=round_num, - audit_round_num=audit_round_num, + round_num=coordinates.round_num, + audit_round_num=coordinates.audit_round_num, lineage_source_required=lineage_source_required, ) @@ -273,12 +268,4 @@ def lineage_payload_order(payload: dict[str, object]) -> tuple[int, int]: and workspace.get("lineage_producer") is True ): return (-1, -1) - round_num = int_field(payload, "round_num") - if round_num is None: - return (-1, -1) - audit_round_num = nullable_int_field(payload, "audit_round_num") - if not audit_round_num.valid: - return (-1, -1) - if audit_round_num.value is None: - return (0, round_num) - return (audit_round_num.value, round_num) + return invocation_round_order(payload) diff --git a/src/crewplane/artifacts/workspace/state/lineage.py b/src/crewplane/artifacts/workspace/state/lineage.py new file mode 100644 index 0000000..f0cbbd8 --- /dev/null +++ b/src/crewplane/artifacts/workspace/state/lineage.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from ...results.selection import parse_audit_round, parse_task_round +from .fields import int_field, nullable_int_field + +INVALID_LINEAGE_ORDER = (-1, -1) + + +@dataclass(frozen=True) +class ReviewOutputCoordinates: + task_id: str + round_num: int + audit_round_num: int | None + + +def review_output_coordinates( + relative_path: str, + expected_task_id: str, +) -> ReviewOutputCoordinates | None: + """Parse and validate task and round coordinates from a review output path.""" + + path = Path(relative_path) + task_id, round_num = parse_task_round(path.stem) + if task_id != expected_task_id or round_num <= 0: + return None + audit_round_num = None + if len(path.parts) > 1: + parsed_audit_round = parse_audit_round(path.parts[0]) + audit_round_num = parsed_audit_round if parsed_audit_round > 0 else None + return ReviewOutputCoordinates(task_id, round_num, audit_round_num) + + +def invocation_round_order(payload: dict[str, object]) -> tuple[int, int]: + """Return the normalized audit/round order or the invalid sentinel.""" + + round_num = int_field(payload, "round_num") + if round_num is None: + return INVALID_LINEAGE_ORDER + audit_round_num = nullable_int_field(payload, "audit_round_num") + if not audit_round_num.valid: + return INVALID_LINEAGE_ORDER + return (audit_round_num.value or 0, round_num) diff --git a/src/crewplane/artifacts/workspace/state/validation.py b/src/crewplane/artifacts/workspace/state/validation.py index ac56284..f51fa80 100644 --- a/src/crewplane/artifacts/workspace/state/validation.py +++ b/src/crewplane/artifacts/workspace/state/validation.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Mapping + from crewplane.architecture.safe_files import contained_regular_file from crewplane.core.file_hashing import file_size_and_sha256 from crewplane.core.preflight.models import ( @@ -466,7 +468,7 @@ def _failed_workspace_state_invoker_matches( def _child_process_environment_matches( - invoker: dict[str, object], + invoker: Mapping[str, object], payload: dict[str, object], ) -> bool: if not _controlled_child_environment_required(invoker): @@ -479,7 +481,7 @@ def _child_process_environment_matches( def _failed_child_process_environment_matches( - invoker: dict[str, object], + invoker: Mapping[str, object], payload: dict[str, object], ) -> bool: if not _controlled_child_environment_required(invoker): @@ -490,7 +492,7 @@ def _failed_child_process_environment_matches( ) -def _controlled_child_environment_required(invoker: dict[str, object]) -> bool: +def _controlled_child_environment_required(invoker: Mapping[str, object]) -> bool: return ( invoker.get("launch_mode") == "runtime_command_runner" and invoker.get("controlled_child_environment") is True diff --git a/src/crewplane/bootstrap/container.py b/src/crewplane/bootstrap/container.py index cf6d1ef..f8078de 100644 --- a/src/crewplane/bootstrap/container.py +++ b/src/crewplane/bootstrap/container.py @@ -107,9 +107,8 @@ def build_runtime_components( def _validate_invoker_contract(invoker: object) -> None: - if not callable(getattr(invoker, "invoke", None)): - raise TypeError("invoker adapter returned object without callable invoke") - if not callable(getattr(invoker, "log_presentation_for", None)): - raise TypeError( - "invoker adapter returned object without callable log_presentation_for" - ) + for method_name in ("invoke", "log_presentation_for"): + if not callable(getattr(invoker, method_name, None)): + raise TypeError( + f"invoker adapter returned object without callable {method_name}" + ) diff --git a/src/crewplane/bootstrap/runtime_config.py b/src/crewplane/bootstrap/runtime_config.py index e3674de..d0301c2 100644 --- a/src/crewplane/bootstrap/runtime_config.py +++ b/src/crewplane/bootstrap/runtime_config.py @@ -113,4 +113,5 @@ def _canonicalize_integration_options( ) else: return canonical_config + # Raise outside the handler so adapter internals are not retained in __context__. raise ValueError(error_message) diff --git a/src/crewplane/cli/app.py b/src/crewplane/cli/app.py index b8ddeac..64dba28 100644 --- a/src/crewplane/cli/app.py +++ b/src/crewplane/cli/app.py @@ -4,6 +4,7 @@ import io import shutil import sys +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import Annotated @@ -22,6 +23,8 @@ from crewplane.core.preflight.source import PreflightWorkflowSource from crewplane.core.state_paths import STATE_DIR_NAME, project_root_from_config_path from crewplane.observability import ObservabilityHub +from crewplane.observability.observer import Observer +from crewplane.observability.types import WorkflowTopology from crewplane.runtime.execution import WorkflowExecutionError, execute_workflow from . import workflow_runner @@ -33,6 +36,7 @@ resolve_tasks_file, ) from .project_init import initialize_project_templates +from .run.observability import ObservabilityHubInstance from .run.resume import print_dry_run_resume_advisory from .update import UpdateError, installed_package_identity, update_crewplane @@ -191,11 +195,27 @@ async def _execute_workflow( no_live=no_live, console=console, execute_workflow_impl=execute_workflow, - observability_hub_cls=ObservabilityHub, + observability_hub_cls=_create_observability_hub, which_fn=shutil.which, ) +def _create_observability_hub( + workflow_topology: WorkflowTopology, + run_id: str, + observers: list[Observer], + refresh_per_second: int = 4, + warning_sink: Callable[[str], None] | None = None, +) -> ObservabilityHubInstance: + return ObservabilityHub( + workflow_topology=workflow_topology, + run_id=run_id, + observers=observers, + refresh_per_second=refresh_per_second, + warning_sink=warning_sink, + ) + + def _compile_preview_for_context( context: CliWorkflowContext, no_live: bool, diff --git a/src/crewplane/cli/cleanup.py b/src/crewplane/cli/cleanup.py index 443f899..9203426 100644 --- a/src/crewplane/cli/cleanup.py +++ b/src/crewplane/cli/cleanup.py @@ -124,15 +124,15 @@ def cleanup_workspaces( console = Console() try: context = resolve_cleanup_workspace_context( - console, - config_file, - successful, - failed, - cancelled, - all_projects, - run_key_name, - older_than, - orphans, + console=console, + config_file=config_file, + successful=successful, + failed=failed, + cancelled=cancelled, + all_projects=all_projects, + run_key_name=run_key_name, + older_than=older_than, + orphans=orphans, ) destructive = yes and not dry_run warn_all_projects_cleanup(console, context.all_projects) diff --git a/src/crewplane/cli/dry_run.py b/src/crewplane/cli/dry_run.py index 536c064..839f461 100644 --- a/src/crewplane/cli/dry_run.py +++ b/src/crewplane/cli/dry_run.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Mapping + from rich.console import Console from crewplane.core.preflight import ( @@ -126,7 +128,7 @@ def _print_workspace_summary( ) -def _workspace_contract_label(descriptor: dict[str, object]) -> object: +def _workspace_contract_label(descriptor: Mapping[str, object]) -> object: contract = descriptor.get("worktree_contract") if isinstance(contract, dict): return contract.get("mode") diff --git a/src/crewplane/cli/run/execution.py b/src/crewplane/cli/run/execution.py index 205155c..905e37a 100644 --- a/src/crewplane/cli/run/execution.py +++ b/src/crewplane/cli/run/execution.py @@ -110,7 +110,7 @@ async def run_and_finalize_workflow( terminalization: TerminalizationCoordinator, resumed_node_ids: tuple[str, ...] = (), ) -> None: - branch_export_records = [] + branch_export_records: tuple[Path, ...] = () persistent_logger: PersistentRunLogger | None = None def complete_scheduler_postconditions() -> None: diff --git a/src/crewplane/cli/run/execution_helpers.py b/src/crewplane/cli/run/execution_helpers.py index 1e4fc45..e629021 100644 --- a/src/crewplane/cli/run/execution_helpers.py +++ b/src/crewplane/cli/run/execution_helpers.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime +from typing import Never import typer @@ -40,7 +41,7 @@ def raise_run_preflight_errors( snapshot_result: RuntimeConfigSnapshotBuildResult, preview: PreflightCompilationPreview, workflow_name: str, -) -> None: +) -> Never: write_preflight_failure_artifacts( context=context, snapshot_result=snapshot_result, diff --git a/src/crewplane/cli/run/historical_summary.py b/src/crewplane/cli/run/historical_summary.py index 4ac938c..bde4cf1 100644 --- a/src/crewplane/cli/run/historical_summary.py +++ b/src/crewplane/cli/run/historical_summary.py @@ -6,10 +6,10 @@ from crewplane.architecture.contracts import ( ArtifactContract, NodeArtifactRequest, - VerifiedNodeArtifact, ) from crewplane.artifacts.atomic import atomic_write_text from crewplane.artifacts.run_history import RunHistoryRecord +from crewplane.core.execution_state import TerminalRunStatus from crewplane.core.preflight.models import PreflightExecutionPlan from crewplane.observability.events import ( ExecutionEvent, @@ -32,26 +32,10 @@ class _HistoricalArtifactStore: source: RunHistoryRecord artifact_contracts: dict[str, ArtifactContract] - @property - def run_id(self) -> str: - return self.source.manifest.run_id - - @property - def run_key_name(self) -> str: - return self.source.manifest.run_key_name - - @property - def task_name(self) -> str: - return self.source.manifest.workflow_name - @property def stages_dir(self) -> Path: return self.source.run_dir - @property - def results_dir(self) -> Path: - return self.source.results_dir - @property def logs_dir(self) -> Path: return self.source.run_dir / "logs" @@ -70,20 +54,7 @@ def get_node_artifact_request( return None if contract is None else NodeArtifactRequest(node_id, contract) def get_node_output_path(self, request: NodeArtifactRequest) -> Path: - return self.results_dir / request.contract.output_path - - def get_node_findings_path(self, request: NodeArtifactRequest) -> Path | None: - findings_path = request.contract.findings_path - return None if findings_path is None else self.results_dir / findings_path - - def read_verified_node_artifact( - self, - request: NodeArtifactRequest, - kind: str, - ) -> VerifiedNodeArtifact: - raise NotImplementedError( - f"Historical artifact '{request.node_id}.{kind}' is not a runtime input." - ) + return self.source.results_dir / request.contract.output_path def refresh_historical_run_summary( @@ -110,7 +81,7 @@ def refresh_historical_run_summary( ) -def _run_result_status(source: RunHistoryRecord) -> str: +def _run_result_status(source: RunHistoryRecord) -> TerminalRunStatus: status = source.manifest.status if status in {"failed", "cancelled"}: return status diff --git a/src/crewplane/cli/run/manifest.py b/src/crewplane/cli/run/manifest.py index 38ee938..03e2e10 100644 --- a/src/crewplane/cli/run/manifest.py +++ b/src/crewplane/cli/run/manifest.py @@ -2,6 +2,7 @@ from datetime import datetime +from crewplane.architecture.contracts import JsonObject from crewplane.architecture.ports import ArtifactStorePort from crewplane.core.execution_state import ( RUN_STATE_SCHEMA_VERSION, @@ -9,6 +10,7 @@ RunStatus, ) from crewplane.core.preflight import PreflightExecutionPlan +from crewplane.core.preflight.serialization import to_json_safe from crewplane.core.preflight.source import PreflightWorkflowSource from crewplane.core.preflight.workspace.observability import ( workspace_observability_descriptor, @@ -86,10 +88,17 @@ def build_run_manifest_from_plan( runtime_config_snapshot_path="preflight/runtime-config-snapshot.json", runtime_config_snapshot=plan.runtime_config_snapshot, workflow_source=source.workflow_content, - composed_workflow=source.composed_workflow, + composed_workflow=_json_object(source.composed_workflow), referenced_workflows=source.referenced_workflow_payloads(), workspace=workspace_observability_descriptor(plan), resumed_nodes=list(resumed_nodes), resume_source_run_id=resume_source_run_id, resume_source_run_key_name=resume_source_run_key_name, ) + + +def _json_object(value: object) -> JsonObject: + payload = to_json_safe(value) + if not isinstance(payload, dict): + raise TypeError("Expected workflow payload to serialize as a JSON object.") + return payload diff --git a/src/crewplane/cli/run/observability.py b/src/crewplane/cli/run/observability.py index e87e933..ae50090 100644 --- a/src/crewplane/cli/run/observability.py +++ b/src/crewplane/cli/run/observability.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass, field -from typing import Protocol, cast +from typing import Protocol from rich.console import Console @@ -138,8 +138,11 @@ def record_warning_event() -> None: class ObservabilityHubInstance(Protocol): - active_observer_count: int - stop_requested: bool + @property + def active_observer_count(self) -> int: ... + + @property + def stop_requested(self) -> bool: ... def __enter__(self) -> ObservabilityHubInstance: ... @@ -264,7 +267,7 @@ def _resolve_observability_hub( ) -> ObservabilityHubFactory: if observability_hub_cls is not None: return observability_hub_cls - return cast(ObservabilityHubFactory, ObservabilityHub) + return ObservabilityHub def _is_live_dashboard_available( diff --git a/src/crewplane/cli/run/resume.py b/src/crewplane/cli/run/resume.py index f8fb901..be4c87c 100644 --- a/src/crewplane/cli/run/resume.py +++ b/src/crewplane/cli/run/resume.py @@ -1,11 +1,13 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime from pathlib import Path from rich.console import Console +from crewplane.architecture.contracts import JsonObject from crewplane.architecture.errors import IntegrationResolutionError from crewplane.architecture.loader import resolve_implementation_path from crewplane.artifacts.resume.decision import ResumeDecision @@ -144,7 +146,7 @@ def print_dry_run_resume_advisory( match resume_plan.decision.kind: case "skip": successful_run = resume_plan.decision.successful_run - branch_export_records = () + branch_export_records: tuple[JsonObject, ...] = () if successful_run is not None: branch_plan = _preview_plan_for_run( preview, @@ -204,7 +206,9 @@ def _preview_plan_for_validation( ) -def _branch_export_verification_failed(records: tuple[dict[str, object], ...]) -> bool: +def _branch_export_verification_failed( + records: tuple[Mapping[str, object], ...], +) -> bool: return any(record.get("status") == "failed_verification" for record in records) diff --git a/src/crewplane/cli/update/detection.py b/src/crewplane/cli/update/detection.py index cb62ef8..eb674ed 100644 --- a/src/crewplane/cli/update/detection.py +++ b/src/crewplane/cli/update/detection.py @@ -215,7 +215,7 @@ def _unsupported_install_error(context: UpdateContext) -> UpdateError: ) if context.metadata.installer == "pip": - command = ( + command: tuple[str, ...] = ( str(context.python_executable), "-m", "pip", diff --git a/src/crewplane/core/preflight/__init__.py b/src/crewplane/core/preflight/__init__.py index ce6fc4f..02d034c 100644 --- a/src/crewplane/core/preflight/__init__.py +++ b/src/crewplane/core/preflight/__init__.py @@ -1,6 +1,9 @@ """Preflight compiler contracts for compiled workflow execution.""" -from .compiler import PreflightCompileOptions, compile_preflight_preview +from crewplane.architecture.contracts import ArtifactContract + +from .compile_state import PreflightCompileOptions +from .compiler import compile_preflight_preview from .diagnostics import ( PreflightDiagnostic, PreflightDiagnosticCode, @@ -9,7 +12,6 @@ from .models import ( PREFLIGHT_STATUS_FAILED, PREFLIGHT_STATUS_SUCCEEDED, - ArtifactContract, DependencyEdge, Fragment, PreflightCompilationPreview, diff --git a/src/crewplane/core/preflight/execution_nodes.py b/src/crewplane/core/preflight/execution_nodes.py index 073c9e6..351e0e6 100644 --- a/src/crewplane/core/preflight/execution_nodes.py +++ b/src/crewplane/core/preflight/execution_nodes.py @@ -18,11 +18,13 @@ source_root, ) from .models import ( + ConcurrencyPolicy, DependencyEdge, ExecutionPolicy, PreflightExecutionNode, ProviderRecord, RenderPlan, + TokenBudgetPolicy, WorkspaceSelectionRecord, ) from .runtime_config import RuntimeConfigSnapshot, runtime_agent_signature_payload @@ -53,12 +55,12 @@ def compile_execution_node( if uses_review_loop else None ), - concurrency_policy={ - "max_concurrent_nodes": runtime_snapshot.execution.max_concurrent_nodes, - "max_parallel_invocations": ( + concurrency_policy=ConcurrencyPolicy( + max_concurrent_nodes=runtime_snapshot.execution.max_concurrent_nodes, + max_parallel_invocations=( runtime_snapshot.execution.max_parallel_invocations ), - }, + ), ) return PreflightExecutionNode( id=node.id, @@ -89,7 +91,7 @@ def nodes_with_graph_dependencies( dependency_graph: list[DependencyEdge], ) -> list[PreflightExecutionNode]: node_order = {node.id: index for index, node in enumerate(nodes)} - dependencies_by_node = {node.id: set() for node in nodes} + dependencies_by_node: dict[str, set[str]] = {node.id: set() for node in nodes} for edge in dependency_graph: dependencies_by_node.setdefault(edge.target_node, set()).add(edge.source_node) return [ @@ -162,7 +164,7 @@ def agent_config_signature( def resolved_token_budget_payload( node: WorkflowNode, config: Config, -) -> dict[str, int | None] | None: +) -> TokenBudgetPolicy | None: if node.mode == "input": return None try: @@ -172,10 +174,10 @@ def resolved_token_budget_payload( ) except ValueError: return None - return { - "fail_threshold_chars": budget.fail_threshold_chars, - "warn_threshold_chars": budget.warn_threshold_chars, - } + return TokenBudgetPolicy( + fail_threshold_chars=budget.fail_threshold_chars, + warn_threshold_chars=budget.warn_threshold_chars, + ) def artifact_task_id(provider: ProviderSpec, index: int) -> str: diff --git a/src/crewplane/core/preflight/fragment_handlers.py b/src/crewplane/core/preflight/fragment_handlers.py index b676b87..832d310 100644 --- a/src/crewplane/core/preflight/fragment_handlers.py +++ b/src/crewplane/core/preflight/fragment_handlers.py @@ -3,6 +3,7 @@ import os import re +from crewplane.architecture.contracts import JsonObject from crewplane.core.prompt_segments import PromptSegmentRole from crewplane.core.workflow.keywords import ( ALLOWED_NODE_ARTIFACT_NAME_SET, @@ -326,8 +327,8 @@ def static_value_fragment( def static_value_resolved_payload( resolution: ResolvedStaticValueReference, -) -> dict[str, str]: - payload = { +) -> JsonObject: + payload: JsonObject = { "kind": "static_env" if resolution.kind == "env" else "static_var", "key": resolution.key, } @@ -355,6 +356,10 @@ def resolve_static_value_reference( state: CompileState, occurrence_id: str, ) -> None: + if reference.kind not in {"env", "var"}: + raise ValueError( + f"Static value resolution requires an env or var reference, got {reference.kind}." + ) key = reference.key or "" value = lookup_static_value(reference.kind, key, variables, options, state, node.id) if value is None: diff --git a/src/crewplane/core/preflight/plan_signatures.py b/src/crewplane/core/preflight/plan_signatures.py index d208f09..8c44a61 100644 --- a/src/crewplane/core/preflight/plan_signatures.py +++ b/src/crewplane/core/preflight/plan_signatures.py @@ -1,5 +1,6 @@ from __future__ import annotations +from crewplane.architecture.contracts import JsonObject, JsonValue from crewplane.core.workflow.models import WorkflowNode from .compile_state import PreflightCompileOptions @@ -76,7 +77,7 @@ def effective_runtime_config_signature_for_plan( def semantic_workspace_runtime_payload( runtime_snapshot: RuntimeConfigSnapshot, nodes: list[PreflightExecutionNode], -) -> dict[str, object]: +) -> JsonObject: policies = [ node.workspace_policy for node in nodes @@ -103,7 +104,7 @@ def template_hash(node: WorkflowNode) -> str: def workspace_source_signature_payload( snapshot: WorkspaceSourceSnapshot | None, -) -> dict[str, object] | None: +) -> JsonObject | None: if snapshot is None: return None return { @@ -119,18 +120,18 @@ def workspace_source_signature_payload( def semantic_referenced_workflows( source: PreflightWorkflowSource, -) -> list[dict[str, str]]: +) -> list[JsonObject]: return [{"path": record.path.as_posix()} for record in source.referenced_workflows] -def semantic_workflow_payload(payload: object) -> object: +def semantic_workflow_payload(payload: object) -> JsonValue: semantic_payload = to_json_safe(payload) if not isinstance(semantic_payload, dict): return semantic_payload nodes = semantic_payload.get("nodes") if isinstance(nodes, list): - normalized_nodes = [] + normalized_nodes: list[JsonValue] = [] for node in nodes: if isinstance(node, dict) and node.get("review_starts_with") == "executor": normalized_node = dict(node) @@ -153,7 +154,7 @@ def semantic_workflow_payload(payload: object) -> object: return semantic_payload -def semantic_worktree_declaration(declaration: object) -> object: +def semantic_worktree_declaration(declaration: object) -> JsonValue: payload = to_json_safe(declaration) if not isinstance(payload, dict): return payload @@ -166,11 +167,11 @@ def semantic_worktree_declaration(declaration: object) -> object: def semantic_node_payloads( nodes: list[PreflightExecutionNode], -) -> list[object]: +) -> list[JsonValue]: return [semantic_node_payload(node) for node in nodes] -def semantic_node_payload(node: PreflightExecutionNode) -> object: +def semantic_node_payload(node: PreflightExecutionNode) -> JsonValue: payload = semantic_source_location_payload(node) if not isinstance(payload, dict): return payload @@ -179,8 +180,8 @@ def semantic_node_payload(node: PreflightExecutionNode) -> object: def semantic_execution_policy_payload( - payload: dict[str, object], -) -> dict[str, object]: + payload: JsonObject, +) -> JsonObject: execution_policy = payload.get("execution_policy") if not isinstance(execution_policy, dict): return payload @@ -191,7 +192,7 @@ def semantic_execution_policy_payload( return {**payload, "execution_policy": normalized_policy} -def semantic_workspace_policy_payload(payload: dict[str, object]) -> dict[str, object]: +def semantic_workspace_policy_payload(payload: JsonObject) -> JsonObject: workspace_policy = payload.get("workspace_policy") if not isinstance(workspace_policy, dict): return payload @@ -200,11 +201,11 @@ def semantic_workspace_policy_payload(payload: dict[str, object]) -> dict[str, o return {**payload, "workspace_policy": normalized_policy} -def semantic_source_location_payload(payload: object) -> object: +def semantic_source_location_payload(payload: object) -> JsonValue: return _without_source_location_metadata(to_json_safe(payload)) -def _without_source_location_metadata(payload: object) -> object: +def _without_source_location_metadata(payload: JsonValue) -> JsonValue: if isinstance(payload, dict): return { key: _without_source_location_metadata(value) diff --git a/src/crewplane/core/preflight/references.py b/src/crewplane/core/preflight/references.py index 9fe4ad8..2f10c9e 100644 --- a/src/crewplane/core/preflight/references.py +++ b/src/crewplane/core/preflight/references.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Literal +from typing import Literal, TypeIs from crewplane.core.workflow.syntax import ( KEY_VALUE_TEMPLATE_PATTERN, @@ -10,6 +10,14 @@ ) TemplateReferenceKind = Literal["node", "file", "env", "var", "param", "unknown"] +KeyValueTemplateReferenceKind = Literal["file", "env", "var", "param"] +_KEY_VALUE_REFERENCE_KINDS = frozenset({"file", "env", "var", "param"}) + + +def _is_key_value_reference_kind( + value: str, +) -> TypeIs[KeyValueTemplateReferenceKind]: + return value in _KEY_VALUE_REFERENCE_KINDS @dataclass(frozen=True) @@ -53,7 +61,7 @@ def iter_template_references(text: str) -> tuple[TemplateReference, ...]: continue reference_kind = key_value_match.group(1).strip() key = key_value_match.group(2).strip() - if reference_kind in {"file", "env", "var", "param"}: + if _is_key_value_reference_kind(reference_kind): references.append( TemplateReference( raw_token=raw_token, diff --git a/src/crewplane/core/preflight/runner.py b/src/crewplane/core/preflight/runner.py index 6fa1e94..c0c45ce 100644 --- a/src/crewplane/core/preflight/runner.py +++ b/src/crewplane/core/preflight/runner.py @@ -14,4 +14,6 @@ def load_workflow_source_for_preflight( tasks_file: Path, project_root: Path, ) -> PreflightWorkflowSource: + """Load and compose a workflow into the source contract consumed by preflight.""" + return _load_workflow_source_for_preflight(tasks_file, project_root) diff --git a/src/crewplane/core/preflight/runtime_config/__init__.py b/src/crewplane/core/preflight/runtime_config/__init__.py index 570ac46..77b2650 100644 --- a/src/crewplane/core/preflight/runtime_config/__init__.py +++ b/src/crewplane/core/preflight/runtime_config/__init__.py @@ -8,6 +8,7 @@ from crewplane.architecture.contracts import ( CanonicalIntegrationConfig, JsonObject, + JsonValue, PromptTransport, ProviderKind, ) @@ -214,7 +215,7 @@ def runtime_agent_execution_payload(config: RuntimeAgentConfigSnapshot) -> JsonO def runtime_agent_snapshot_payloads( agents: Mapping[str, RuntimeAgentConfigSnapshot], -) -> dict[str, JsonObject]: +) -> JsonObject: return { name: runtime_agent_snapshot_payload(agent) for name, agent in sorted(agents.items()) @@ -223,7 +224,7 @@ def runtime_agent_snapshot_payloads( def runtime_agent_effective_payloads( agents: Mapping[str, RuntimeAgentConfigSnapshot], -) -> dict[str, JsonObject]: +) -> JsonObject: return { name: runtime_agent_effective_payload(agent) for name, agent in sorted(agents.items()) @@ -294,7 +295,7 @@ def build( sequential_consensus_on_exhaustion=settings.sequential_consensus_on_exhaustion, token_budget=settings.token_budget, ) - raw_agents = { + raw_agents: JsonObject = { name: agent_config_input_payload(agent) for name, agent in sorted(config.agents.items()) } @@ -344,7 +345,7 @@ def build( "ui", None, ) - payload = { + payload: JsonObject = { "agents": runtime_agent_snapshot_payloads(agent_snapshots), "artifacts": redacted_artifacts.scoped_payload({"artifact", "execution"}), "execution": execution_effective_payload(execution), @@ -394,8 +395,12 @@ def with_sensitive_config_fingerprints( ) } ) + raw_agents = self.raw_agents or { + name: runtime_agent_snapshot_payload(agent) + for name, agent in self.agents.items() + } agents, fingerprints = redact_sensitive_config_with_fingerprints( - self.raw_agents or self.agents, + raw_agents, fingerprint_key, ) agent_snapshots = runtime_agent_snapshots(agents) @@ -467,7 +472,7 @@ def _effective_signature( ) -> str: signature_invoker = invoker or self.invoker signature_artifacts = artifacts or self.artifacts - payload = { + payload: JsonObject = { "agents": runtime_agent_effective_payloads(agents), "artifacts": signature_artifacts.scoped_payload({"artifact", "execution"}), "execution": execution_effective_payload(self.execution), @@ -528,7 +533,7 @@ def runtime_config_signature( workspace_payload: JsonObject, nodes: list[PreflightExecutionNode], ) -> str: - payload = { + payload: JsonObject = { "agent_invocations": runtime_agent_invocation_payloads( snapshot.agents, nodes, @@ -546,7 +551,7 @@ def runtime_config_signature( def runtime_agent_invocation_payloads( agents: Mapping[str, RuntimeAgentConfigSnapshot], nodes: list[PreflightExecutionNode], -) -> list[JsonObject]: +) -> list[JsonValue]: return [ { "agent_config": runtime_agent_signature_payload( diff --git a/src/crewplane/core/preflight/runtime_config/redaction.py b/src/crewplane/core/preflight/runtime_config/redaction.py index 8ca69fa..b9ad383 100644 --- a/src/crewplane/core/preflight/runtime_config/redaction.py +++ b/src/crewplane/core/preflight/runtime_config/redaction.py @@ -8,6 +8,7 @@ from __future__ import annotations import re +from typing import Literal from crewplane.architecture.contracts import ( CanonicalIntegrationConfig, @@ -33,6 +34,7 @@ ) _ARGV_FIELD_NAMES = frozenset({"argv", "cli_cmd", "extra_args"}) _ARGV_SCALAR_FIELD_NAMES = frozenset({"model_arg", "prompt_transport_arg"}) +type RedactionOutput = Literal["redacted", "fingerprinted"] def config_value_handle(path: str) -> str: @@ -43,7 +45,12 @@ def redact_sensitive_config( payload: JsonObject, root_path: tuple[str, ...] = ("agents",), ) -> tuple[JsonObject, list[str]]: - redacted, paths = _redact_sensitive_value(payload, root_path, None) + redacted, paths, _ = _redact_sensitive_value( + payload, + root_path, + None, + "redacted", + ) return _ensure_dict(redacted), sorted(paths) @@ -52,10 +59,11 @@ def redact_sensitive_config_with_fingerprints( fingerprint_key: bytes | None, root_path: tuple[str, ...] = ("agents",), ) -> tuple[JsonObject, list[dict[str, str]]]: - redacted, _, fingerprints = _redact_sensitive_value_with_fingerprints( + redacted, _, fingerprints = _redact_sensitive_value( payload, root_path, fingerprint_key, + "fingerprinted", ) return _ensure_dict(redacted), sorted(fingerprints, key=lambda item: item["path"]) @@ -140,48 +148,18 @@ def config_fingerprint( def _redact_sensitive_value( - value: JsonValue, - path: tuple[str, ...], - list_parent: str | None, -) -> tuple[JsonValue, list[str]]: - if _is_sensitive_config_value(value, path, list_parent): - return {"redacted": True}, [_path_label(path)] - if isinstance(value, dict): - paths: list[str] = [] - redacted: JsonObject = {} - for key, child in sorted(value.items()): - child_value, child_paths = _redact_sensitive_value( - child, - (*path, str(key)), - None, - ) - redacted[str(key)] = child_value - paths.extend(child_paths) - boundary_secret = _command_field_boundary_secret(value, path) - if boundary_secret is not None: - secret_path = boundary_secret[1] - path_label = _path_label(secret_path) - if path_label not in paths: - _replace_first_extra_arg(redacted, {"redacted": True}) - paths.append(path_label) - return redacted, paths - if isinstance(value, list): - return _redact_sensitive_list_without_fingerprints(value, path) - return value, [] - - -def _redact_sensitive_value_with_fingerprints( value: JsonValue, path: tuple[str, ...], fingerprint_key: bytes | None, + output: RedactionOutput, list_parent: str | None = None, ) -> tuple[JsonValue, list[str], list[dict[str, str]]]: if _is_sensitive_config_value(value, path, list_parent): - return _redacted_sensitive_leaf(value, path, fingerprint_key) + return _redacted_sensitive_leaf(value, path, fingerprint_key, output) if isinstance(value, dict): - return _redacted_sensitive_dict(value, path, fingerprint_key) + return _redacted_sensitive_dict(value, path, fingerprint_key, output) if isinstance(value, list): - return _redacted_sensitive_list(value, path, fingerprint_key) + return _redacted_sensitive_list(value, path, fingerprint_key, output) return value, [], [] @@ -189,13 +167,13 @@ def _redacted_sensitive_leaf( value: JsonValue, path: tuple[str, ...], fingerprint_key: bytes | None, + output: RedactionOutput, ) -> tuple[JsonObject, list[str], list[dict[str, str]]]: path_label = _path_label(path) fingerprint = config_fingerprint(fingerprint_key, path_label, value) - redacted_value: JsonObject = { - "redacted": True, - "value_handle": config_value_handle(path_label), - } + redacted_value: JsonObject = {"redacted": True} + if output == "fingerprinted": + redacted_value["value_handle"] = config_value_handle(path_label) if fingerprint is None: return redacted_value, [path_label], [] redacted_value["fingerprint"] = fingerprint @@ -210,17 +188,17 @@ def _redacted_sensitive_dict( value: dict[str, JsonValue], path: tuple[str, ...], fingerprint_key: bytes | None, + output: RedactionOutput, ) -> tuple[JsonObject, list[str], list[dict[str, str]]]: paths: list[str] = [] fingerprints: list[dict[str, str]] = [] redacted: JsonObject = {} for key, child in sorted(value.items()): - child_value, child_paths, child_fingerprints = ( - _redact_sensitive_value_with_fingerprints( - child, - (*path, str(key)), - fingerprint_key, - ) + child_value, child_paths, child_fingerprints = _redact_sensitive_value( + child, + (*path, str(key)), + fingerprint_key, + output, ) redacted[str(key)] = child_value paths.extend(child_paths) @@ -231,7 +209,10 @@ def _redacted_sensitive_dict( path_label = _path_label(secret_path) if path_label not in paths: redacted_value, child_paths, child_fingerprints = _redacted_sensitive_leaf( - secret_value, secret_path, fingerprint_key + secret_value, + secret_path, + fingerprint_key, + output, ) _replace_first_extra_arg(redacted, redacted_value) paths.extend(child_paths) @@ -243,30 +224,31 @@ def _redacted_sensitive_list( value: list[JsonValue], path: tuple[str, ...], fingerprint_key: bytes | None, + output: RedactionOutput, ) -> tuple[list[JsonValue], list[str], list[dict[str, str]]]: - paths = [] - fingerprints = [] - redacted_list = [] + paths: list[str] = [] + fingerprints: list[dict[str, str]] = [] + redacted_list: list[JsonValue] = [] sensitive_indices = _sensitive_argv_indices(value, path) for index, child in enumerate(value): child_path = (*path, str(index)) if index in sensitive_indices: - child_value, child_paths, child_fingerprints = _redacted_sensitive_leaf( + sensitive_value, child_paths, child_fingerprints = _redacted_sensitive_leaf( child, child_path, fingerprint_key, + output, ) - redacted_list.append(child_value) + redacted_list.append(sensitive_value) paths.extend(child_paths) fingerprints.extend(child_fingerprints) continue - child_value, child_paths, child_fingerprints = ( - _redact_sensitive_value_with_fingerprints( - child, - child_path, - fingerprint_key, - path[-1] if path else None, - ) + child_value, child_paths, child_fingerprints = _redact_sensitive_value( + child, + child_path, + fingerprint_key, + output, + path[-1] if path else None, ) redacted_list.append(child_value) paths.extend(child_paths) @@ -319,29 +301,6 @@ def _replace_first_extra_arg( extra_args[0] = value -def _redact_sensitive_list_without_fingerprints( - value: list[JsonValue], - path: tuple[str, ...], -) -> tuple[list[JsonValue], list[str]]: - paths = [] - redacted_list = [] - sensitive_indices = _sensitive_argv_indices(value, path) - for index, child in enumerate(value): - child_path = (*path, str(index)) - if index in sensitive_indices: - redacted_list.append({"redacted": True}) - paths.append(_path_label(child_path)) - continue - child_value, child_paths = _redact_sensitive_value( - child, - child_path, - path[-1] if path else None, - ) - redacted_list.append(child_value) - paths.extend(child_paths) - return redacted_list, paths - - def _sensitive_argv_indices( value: list[JsonValue], path: tuple[str, ...], diff --git a/src/crewplane/core/preflight/serialization.py b/src/crewplane/core/preflight/serialization.py index 0ee0586..e150430 100644 --- a/src/crewplane/core/preflight/serialization.py +++ b/src/crewplane/core/preflight/serialization.py @@ -37,6 +37,8 @@ def canonical_json(value: object) -> str: def canonical_json_bytes(value: object) -> bytes: + """Return deterministic UTF-8 JSON bytes for signing or persistence.""" + return canonical_json(value).encode("utf-8") diff --git a/src/crewplane/core/preflight/static_resources.py b/src/crewplane/core/preflight/static_resources.py index b6b7d07..7ee4220 100644 --- a/src/crewplane/core/preflight/static_resources.py +++ b/src/crewplane/core/preflight/static_resources.py @@ -179,7 +179,7 @@ def _file_diagnostic( message: str, resolved_path: Path | None = None, ) -> StaticFileResult: - metadata = {} + metadata: dict[str, str | int | bool | None] = {} if resolved_path is not None: metadata["resolved_path"] = resolved_path.as_posix() return StaticFileResult( diff --git a/src/crewplane/core/preflight/token_catalog.py b/src/crewplane/core/preflight/token_catalog.py index 5fdbcb0..973e199 100644 --- a/src/crewplane/core/preflight/token_catalog.py +++ b/src/crewplane/core/preflight/token_catalog.py @@ -7,7 +7,7 @@ from crewplane.core.workflow.source_locations import SourceSpan from .compile_state import CompileState, PreflightCompileOptions, source_file -from .models import TokenCatalogEntry +from .models import TokenCatalogEntry, TokenKind from .references import TemplateReference @@ -18,7 +18,7 @@ def append_token_catalog( target_role: ProviderRole, source_role: PromptSegmentRole, reference: TemplateReference, - token_kind: str, + token_kind: TokenKind, fragment_index: int, signature: str, metadata: dict[str, str], diff --git a/src/crewplane/core/preflight/value_fingerprints.py b/src/crewplane/core/preflight/value_fingerprints.py index 34fc42b..c826280 100644 --- a/src/crewplane/core/preflight/value_fingerprints.py +++ b/src/crewplane/core/preflight/value_fingerprints.py @@ -2,6 +2,8 @@ from dataclasses import replace +from crewplane.architecture.contracts import JsonObject + from .compile_state import CompileState, PreflightCompileOptions, extend_diagnostics from .secrets import ( FINGERPRINT_PAYLOAD_VERSION, @@ -35,7 +37,7 @@ def backfill_value_fingerprints(state: CompileState) -> None: return for record in state.value_fingerprints: raw_value = record.pop("value") - payload = { + payload: JsonObject = { "fingerprint_payload_version": FINGERPRINT_PAYLOAD_VERSION, "key": record["key"], "kind": record["kind"], diff --git a/src/crewplane/core/preflight/variables.py b/src/crewplane/core/preflight/variables.py index c57b9c2..1adbd95 100644 --- a/src/crewplane/core/preflight/variables.py +++ b/src/crewplane/core/preflight/variables.py @@ -4,4 +4,6 @@ def build_builtin_template_variables(project_root: Path) -> dict[str, str]: + """Build deterministic template variables derived from the project root.""" + return {"project_name": project_root.resolve().name} diff --git a/src/crewplane/core/preflight/workspace/files/locators.py b/src/crewplane/core/preflight/workspace/files/locators.py index 3e620ea..473f7af 100644 --- a/src/crewplane/core/preflight/workspace/files/locators.py +++ b/src/crewplane/core/preflight/workspace/files/locators.py @@ -4,6 +4,7 @@ import subprocess from pathlib import Path, PurePosixPath +from crewplane.architecture.contracts import JsonObject from crewplane.core.workflow.models import WorkflowNode, WorkflowPlan from ...compile_state import ( @@ -285,8 +286,8 @@ def token_signature_for_workspace_locator(locator: WorkspaceFileLocator) -> str: def workspace_locator_resolved_payload( locator: WorkspaceFileLocator, -) -> dict[str, str]: - payload = { +) -> JsonObject: + payload: JsonObject = { "kind": "workspace_file_locator", "locator_id": locator.locator_id, "source_class": locator.source_class, diff --git a/src/crewplane/core/preflight/workspace/observability.py b/src/crewplane/core/preflight/workspace/observability.py index 896cb17..e53af31 100644 --- a/src/crewplane/core/preflight/workspace/observability.py +++ b/src/crewplane/core/preflight/workspace/observability.py @@ -61,6 +61,10 @@ def workspace_source_descriptor( ) -> JsonObject | None: if source is None: return None + local_config_policy: JsonObject = { + key: list(values) for key, values in source.local_config_policy.items() + } + filesystem_capabilities: JsonObject = dict(source.filesystem_capabilities) return { "object_format": source.object_format, "repo_id": source.repository_id, @@ -70,8 +74,8 @@ def workspace_source_descriptor( "project_root_relative_path": source.project_root_relative_path, "clean_start": source.clean_start, "worktree_contract": source.worktree_contract.model_dump(mode="json"), - "local_config_policy": source.local_config_policy, - "filesystem_capabilities": source.filesystem_capabilities, + "local_config_policy": local_config_policy, + "filesystem_capabilities": filesystem_capabilities, } diff --git a/src/crewplane/core/value_checks.py b/src/crewplane/core/value_checks.py index ce16f1d..cce717e 100644 --- a/src/crewplane/core/value_checks.py +++ b/src/crewplane/core/value_checks.py @@ -1,12 +1,16 @@ from __future__ import annotations -from typing import TypeIs +from typing import TypeGuard, TypeIs def is_strict_int(value: object) -> TypeIs[int]: return isinstance(value, int) and not isinstance(value, bool) +def is_nonnegative_int(value: object) -> TypeGuard[int]: + return is_strict_int(value) and value >= 0 + + def positive_strict_int(value: object) -> int | None: if is_strict_int(value) and value > 0: return value diff --git a/src/crewplane/core/workflow/validation/api.py b/src/crewplane/core/workflow/validation/api.py index 8d94b60..a2e3ed6 100644 --- a/src/crewplane/core/workflow/validation/api.py +++ b/src/crewplane/core/workflow/validation/api.py @@ -58,6 +58,8 @@ def validate_workflow_plan(workflow: WorkflowPlan) -> WorkflowPlan: def collect_workflow_validation_diagnostics( workflow: WorkflowPlan, ) -> tuple[WorkflowValidationDiagnostic, ...]: + """Collect structural, topology, and template diagnostics for a workflow.""" + return ( *collect_workflow_node_diagnostics(workflow), *collect_workflow_topology_diagnostics(workflow), @@ -68,6 +70,8 @@ def collect_workflow_validation_diagnostics( def collect_workflow_topology_diagnostics( workflow: WorkflowPlan, ) -> tuple[WorkflowValidationDiagnostic, ...]: + """Collect diagnostics that require a structurally valid dependency graph.""" + node_ids = {node.id for node in workflow.nodes} if len(node_ids) != len(workflow.nodes): return () @@ -94,6 +98,8 @@ def collect_provider_validation_diagnostics( workflow: WorkflowPlan, config: Config, ) -> tuple[WorkflowValidationDiagnostic, ...]: + """Collect diagnostics for workflow provider references and role policies.""" + return tuple( WorkflowValidationDiagnostic( code="WORKFLOW-PROVIDER", @@ -108,6 +114,8 @@ def collect_workflow_policy_diagnostics( workflow: WorkflowPlan, config: Config, ) -> tuple[WorkflowValidationDiagnostic, ...]: + """Collect workspace, audit-round, and token-budget policy diagnostics.""" + audit_round_messages = collect_audit_rounds_validation_errors(workflow, config) token_budget_messages = collect_token_budget_validation_errors(workflow, config) return ( diff --git a/src/crewplane/core/workflow/validation/policies.py b/src/crewplane/core/workflow/validation/policies.py index 5742ea7..3606116 100644 --- a/src/crewplane/core/workflow/validation/policies.py +++ b/src/crewplane/core/workflow/validation/policies.py @@ -10,6 +10,8 @@ def validate_audit_rounds_settings(workflow: WorkflowPlan, config: Config) -> None: + """Raise when a node exceeds the configured audit-round limit.""" + errors = collect_audit_rounds_validation_errors(workflow, config) if errors: raise ValueError("\n".join(errors)) @@ -19,6 +21,8 @@ def collect_audit_rounds_validation_errors( workflow: WorkflowPlan, config: Config, ) -> list[str]: + """Return audit-round policy validation errors in stable display form.""" + max_audit_rounds = config.settings.max_audit_rounds return [ ( @@ -34,6 +38,8 @@ def collect_provider_validation_errors( workflow: WorkflowPlan, config: Config, ) -> list[str]: + """Return unknown-provider validation errors in stable display form.""" + return _format_unknown_provider_errors( collect_missing_provider_locations(workflow, config) ) @@ -43,6 +49,8 @@ def collect_token_budget_validation_errors( workflow: WorkflowPlan, config: Config, ) -> list[str]: + """Return node-scoped token-budget policy errors for a workflow.""" + errors: list[str] = [] settings_budget = config.settings.token_budget for node in workflow.nodes: @@ -59,6 +67,8 @@ def collect_workspace_validation_diagnostics( workflow: WorkflowPlan, config: Config, ) -> tuple[WorkflowValidationDiagnostic, ...]: + """Return workspace policy diagnostics for a workflow and configuration.""" + return collect_workspace_policy_diagnostics(workflow, config) @@ -66,6 +76,8 @@ def collect_missing_provider_locations( workflow: WorkflowPlan, config: Config, ) -> dict[str, tuple[str, ...]]: + """Map each unknown provider to the workflow locations that reference it.""" + missing_provider_locations: dict[str, list[str]] = {} for node in workflow.nodes: for provider in node.providers: @@ -85,12 +97,16 @@ def validate_provider_references( workflow: WorkflowPlan, config: Config, ) -> None: + """Raise when the workflow references providers absent from configuration.""" + errors = collect_provider_validation_errors(workflow, config) if errors: raise ValueError("\n".join(errors)) def validate_token_budget_settings(workflow: WorkflowPlan, config: Config) -> None: + """Raise when an executable node has an invalid effective token budget.""" + errors = collect_token_budget_validation_errors(workflow, config) if errors: raise ValueError("\n".join(errors)) diff --git a/src/crewplane/core/workflow/validation/templates.py b/src/crewplane/core/workflow/validation/templates.py index 5471d35..1b28f52 100644 --- a/src/crewplane/core/workflow/validation/templates.py +++ b/src/crewplane/core/workflow/validation/templates.py @@ -31,6 +31,8 @@ class NodeArtifactReference: def extract_template_tokens(prompt: str) -> list[str]: + """Return template tokens in their source order, including delimiters.""" + return [match.group(0) for match in TEMPLATE_TOKEN_PATTERN.finditer(prompt)] diff --git a/src/crewplane/core/workflow/validation/workspace_diagnostics.py b/src/crewplane/core/workflow/validation/workspace_diagnostics.py index 542fd60..ed42d9a 100644 --- a/src/crewplane/core/workflow/validation/workspace_diagnostics.py +++ b/src/crewplane/core/workflow/validation/workspace_diagnostics.py @@ -51,7 +51,7 @@ def workspace_policy_diagnostics( def _duplicate_branch_export_diagnostics( workflow: WorkflowPlan, - selected_worktrees: set[str | None], + selected_worktrees: set[str], ) -> tuple[WorkflowValidationDiagnostic, ...]: worktrees_by_branch: dict[str, list[str]] = {} for name, declaration in workflow.worktrees.items(): @@ -90,7 +90,7 @@ def _duplicate_branch_export_diagnostics( def _unselected_branch_export_diagnostics( workflow: WorkflowPlan, - selected_worktrees: set[str | None], + selected_worktrees: set[str], ) -> tuple[WorkflowValidationDiagnostic, ...]: diagnostics: list[WorkflowValidationDiagnostic] = [] for name, declaration in workflow.worktrees.items(): diff --git a/src/crewplane/core/yaml_loader.py b/src/crewplane/core/yaml_loader.py index 5682cf6..557812f 100644 --- a/src/crewplane/core/yaml_loader.py +++ b/src/crewplane/core/yaml_loader.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Any - import yaml from yaml.constructor import ConstructorError from yaml.nodes import MappingNode @@ -54,7 +52,7 @@ def _construct_mapping_with_unique_keys( ) -def load_yaml_unique(text: str) -> Any: +def load_yaml_unique(text: str) -> object: """Load YAML text while rejecting duplicate mapping keys.""" return yaml.load(text, Loader=UniqueKeyLoader) diff --git a/src/crewplane/observability/dag_graph.py b/src/crewplane/observability/dag_graph.py index 12a4555..7fdf16c 100644 --- a/src/crewplane/observability/dag_graph.py +++ b/src/crewplane/observability/dag_graph.py @@ -114,7 +114,7 @@ def dependent_map_for( dependencies: dict[str, tuple[str, ...]], layout: TopologyLayout, ) -> dict[str, tuple[str, ...]]: - dependents = {node_id: [] for node_id in layout.node_order} + dependents: dict[str, list[str]] = {node_id: [] for node_id in layout.node_order} for node_id, dependency_ids in dependencies.items(): for dependency_id in dependency_ids: dependents[dependency_id].append(node_id) diff --git a/src/crewplane/observability/events/builders.py b/src/crewplane/observability/events/builders.py index 74f4542..a94fef1 100644 --- a/src/crewplane/observability/events/builders.py +++ b/src/crewplane/observability/events/builders.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Mapping +from typing import NotRequired, TypedDict from crewplane.architecture.contracts import OutputExtractionStatus from crewplane.observability.events.execution_event import ( @@ -26,6 +27,16 @@ ) +class _ExecutionEventKwargs(TypedDict): + event_type: EventType + workflow_name: str + run_id: str + context: ExecutionEventContext + payload: EventPayload + timestamp: NotRequired[float] + timestamp_utc: NotRequired[str] + + def workflow_event( event_type: WorkflowEventType, workflow_name: str, @@ -203,7 +214,7 @@ def _build_event( timestamp: float | None, timestamp_utc: str | None, ) -> ExecutionEvent: - kwargs: dict[str, object] = { + kwargs: _ExecutionEventKwargs = { "event_type": event_type, "workflow_name": workflow_name, "run_id": run_id, diff --git a/src/crewplane/observability/events/reducer.py b/src/crewplane/observability/events/reducer.py index 0ee6be3..df9cce0 100644 --- a/src/crewplane/observability/events/reducer.py +++ b/src/crewplane/observability/events/reducer.py @@ -1,5 +1,7 @@ from __future__ import annotations +from crewplane.architecture.contracts import validate_log_presentation_format +from crewplane.core.workflow.keywords import ProviderRole from crewplane.observability.events.dashboard_state import ( InvocationRuntimeState, NodeRuntimeState, @@ -133,6 +135,12 @@ def require_invocation( raise ValueError("Invocation event missing provider.") if not context.role: raise ValueError("Invocation event missing role.") + role = ProviderRole(context.role) + presentation_format = ( + validate_log_presentation_format(context.log_presentation_format) + if context.log_presentation_format is not None + else None + ) invocation_key = invocation_key_for( context.task_id, @@ -144,13 +152,13 @@ def require_invocation( invocation = InvocationRuntimeState( task_id=context.task_id, provider=context.provider or "", - role=context.role or "", + role=role, model=context.model, audit_round_num=context.audit_round_num, round_num=context.round_num, output_file=context.output_file, log_file=context.log_file, - log_presentation_format=context.log_presentation_format, + log_presentation_format=presentation_format, log_presentation_profile=context.log_presentation_profile, ) node.invocations[invocation_key] = invocation @@ -160,7 +168,7 @@ def require_invocation( if context.log_file is not None: invocation.log_file = context.log_file if context.log_presentation_format is not None: - invocation.log_presentation_format = context.log_presentation_format + invocation.log_presentation_format = presentation_format if context.log_presentation_profile is not None: invocation.log_presentation_profile = context.log_presentation_profile diff --git a/src/crewplane/observability/log_headers.py b/src/crewplane/observability/log_headers.py new file mode 100644 index 0000000..f8f489a --- /dev/null +++ b/src/crewplane/observability/log_headers.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +_PROVIDER_LOG_HEADER_PREFIXES = ( + "started_at:", + "cli_executable:", + "model:", + "output_file:", +) +_OPTIONAL_REASONING_PREFIX = "requested_reasoning:" + + +def provider_log_body_start(head: bytes) -> int: + """Return the byte offset after a valid provider-log header, or zero.""" + + header_lines: list[str] = [] + body_start = 0 + for raw_line in head.splitlines(keepends=True): + stripped = raw_line.rstrip(b"\r\n") + body_start += len(raw_line) + if stripped == b"---": + break + if stripped: + header_lines.append(stripped.decode("utf-8", errors="replace").strip()) + else: + return 0 + + if len(header_lines) == len(_PROVIDER_LOG_HEADER_PREFIXES) + 1 and header_lines[ + 3 + ].startswith(_OPTIONAL_REASONING_PREFIX): + header_lines.pop(3) + if len(header_lines) != len(_PROVIDER_LOG_HEADER_PREFIXES): + return 0 + if any( + not line.startswith(prefix) + for line, prefix in zip( + header_lines, + _PROVIDER_LOG_HEADER_PREFIXES, + strict=True, + ) + ): + return 0 + return body_start diff --git a/src/crewplane/observability/log_presentation/formatters.py b/src/crewplane/observability/log_presentation/formatters.py index c59b4e4..4afde29 100644 --- a/src/crewplane/observability/log_presentation/formatters.py +++ b/src/crewplane/observability/log_presentation/formatters.py @@ -5,7 +5,6 @@ from json import JSONDecodeError from pathlib import Path from time import time -from typing import Any from crewplane.architecture.contracts import ( LogPresentationDescriptor, @@ -30,7 +29,7 @@ @dataclass(frozen=True) class _JsonObjectRecovery: - parsed: Any + parsed: object diagnostics: tuple[str, ...] = () @@ -193,9 +192,6 @@ def format_json_object(request: LogPresentationRequest) -> LogPresentationSnapsh ) JSON_OBJECT_THROTTLE.clear_path(request.log_path) - notices: list[LogPresentationNotice] = [] - if result.truncated: - notices.append(warning_notice("Structured log was read from a bounded tail.")) if exceeds_json_depth(parsed, request.limits.max_json_depth): return fallback_text_snapshot( result, @@ -211,7 +207,7 @@ def format_json_object(request: LogPresentationRequest) -> LogPresentationSnapsh size_bytes=result.size_bytes, updated_age_seconds=result.updated_age_seconds, lines=tuple(rendered[: request.line_budget]), - notices=tuple(notices), + notices=(), truncated=result.truncated, ) @@ -332,7 +328,7 @@ def _recover_claude_json_with_inner_diagnostics( ) -def _parse_claude_shaped_json(candidate: str) -> Any | None: +def _parse_claude_shaped_json(candidate: str) -> object | None: try: parsed = json.loads(candidate) except (RecursionError, ValueError): diff --git a/src/crewplane/observability/log_presentation/json_extract.py b/src/crewplane/observability/log_presentation/json_extract.py index bb101e0..b480dec 100644 --- a/src/crewplane/observability/log_presentation/json_extract.py +++ b/src/crewplane/observability/log_presentation/json_extract.py @@ -2,7 +2,6 @@ import json from collections.abc import Mapping -from typing import Any from .limits import DEFAULT_LIMITS, LogPresentationLimits from .sanitize import clip_text, redact_json_value, sanitize_line @@ -18,8 +17,8 @@ ) -def exceeds_json_depth(value: Any, max_depth: int) -> bool: - stack: list[tuple[Any, int]] = [(value, 1)] +def exceeds_json_depth(value: object, max_depth: int) -> bool: + stack: list[tuple[object, int]] = [(value, 1)] while stack: current, depth = stack.pop() if depth > max_depth: @@ -33,7 +32,7 @@ def exceeds_json_depth(value: Any, max_depth: int) -> bool: def compact_json_line( - value: Any, + value: object, limits: LogPresentationLimits = DEFAULT_LIMITS, ) -> str: redacted = redact_json_value(value) @@ -50,7 +49,7 @@ def compact_json_line( def render_json_record( - record: Any, + record: object, profile: str, limits: LogPresentationLimits = DEFAULT_LIMITS, ) -> list[str]: @@ -58,6 +57,8 @@ def render_json_record( return [compact_json_line(record, limits)] redacted = redact_json_value(record) + if not isinstance(redacted, Mapping): + return [compact_json_line(redacted, limits)] if profile == "mock": return render_mock_record(redacted, limits) if profile == "codex": @@ -68,13 +69,15 @@ def render_json_record( def render_json_object( - value: Any, + value: object, profile: str, limits: LogPresentationLimits = DEFAULT_LIMITS, ) -> list[str]: if not isinstance(value, Mapping): return [compact_json_line(value, limits)] redacted = redact_json_value(value) + if not isinstance(redacted, Mapping): + return [compact_json_line(redacted, limits)] if profile == "claude": return render_claude_object(redacted, limits) if profile == "gemini": @@ -83,7 +86,7 @@ def render_json_object( def render_mock_record( - record: Mapping[str, Any], + record: Mapping[object, object], limits: LogPresentationLimits, ) -> list[str]: fields = [ @@ -97,7 +100,7 @@ def render_mock_record( def render_codex_record( - record: Mapping[str, Any], + record: Mapping[object, object], limits: LogPresentationLimits, ) -> list[str]: for key in _CODEX_DIRECT_CONTENT_FIELDS: @@ -115,14 +118,14 @@ def render_codex_record( return [sanitize_line(label, limits)] item = record.get("item") if isinstance(item, Mapping): - detail = _codex_item_detail(record, item, limits) - if detail: - return [sanitize_line(f"item: {detail}", limits)] + item_detail = _codex_item_detail(record, item, limits) + if item_detail: + return [sanitize_line(f"item: {item_detail}", limits)] return render_generic_record(record, limits) def render_kilo_record( - record: Mapping[str, Any], + record: Mapping[object, object], limits: LogPresentationLimits, ) -> list[str]: event_type = _string_field(record, "type") @@ -139,7 +142,7 @@ def render_kilo_record( def render_gemini_object( - record: Mapping[str, Any], + record: Mapping[object, object], limits: LogPresentationLimits, ) -> list[str]: response = _string_field(record, "response") @@ -149,7 +152,7 @@ def render_gemini_object( def render_claude_object( - record: Mapping[str, Any], + record: Mapping[object, object], limits: LogPresentationLimits, ) -> list[str]: lines: list[str] = [] @@ -173,7 +176,7 @@ def render_claude_object( def render_generic_record( - record: Mapping[str, Any], + record: Mapping[object, object], limits: LogPresentationLimits, ) -> list[str]: for key in ("message", "content", "text", "result", "error"): @@ -200,14 +203,17 @@ def display_string_lines( return lines -def _field(key: str, record: Mapping[str, Any]) -> str | None: +def _field(key: str, record: Mapping[object, object]) -> str | None: value = record.get(key) if value is None: return None return f"{key}={value}" -def _codex_detail(record: Mapping[str, Any], limits: LogPresentationLimits) -> str: +def _codex_detail( + record: Mapping[object, object], + limits: LogPresentationLimits, +) -> str: for key in ("message", "content", "text"): value = record.get(key) if isinstance(value, str) and value.strip(): @@ -225,7 +231,7 @@ def _codex_detail(record: Mapping[str, Any], limits: LogPresentationLimits) -> s def _codex_item_event_lines( - record: Mapping[str, Any], + record: Mapping[object, object], event_type: str, limits: LogPresentationLimits, ) -> list[str] | None: @@ -251,8 +257,8 @@ def _codex_item_event_lines( def _codex_command_execution_lines( - record: Mapping[str, Any], - item: Mapping[str, Any], + record: Mapping[object, object], + item: Mapping[object, object], item_type: str, phase: str, limits: LogPresentationLimits, @@ -274,8 +280,8 @@ def _codex_command_execution_lines( def _codex_command_execution_metadata( - record: Mapping[str, Any], - item: Mapping[str, Any], + record: Mapping[object, object], + item: Mapping[object, object], limits: LogPresentationLimits, ) -> str: components: list[str] = [] @@ -293,8 +299,8 @@ def _codex_command_execution_metadata( def _codex_item_detail( - record: Mapping[str, Any], - item: Mapping[str, Any], + record: Mapping[object, object], + item: Mapping[object, object], limits: LogPresentationLimits, ) -> str | None: components: list[str] = [] @@ -328,16 +334,18 @@ def _codex_item_detail( return None -def _codex_web_search_detail(item: Mapping[str, Any]) -> str | None: +def _codex_web_search_detail(item: Mapping[object, object]) -> str | None: for value in _codex_web_search_detail_candidates(item): if isinstance(value, str) and value.strip(): return value.strip() return None -def _codex_web_search_detail_candidates(item: Mapping[str, Any]) -> list[Any]: +def _codex_web_search_detail_candidates( + item: Mapping[object, object], +) -> list[object]: action = item.get("action") - candidates: list[Any] = [] + candidates: list[object] = [] if isinstance(action, Mapping): candidates.append(action.get("query")) queries = action.get("queries") @@ -347,12 +355,12 @@ def _codex_web_search_detail_candidates(item: Mapping[str, Any]) -> list[Any]: return candidates -def _codex_is_empty_web_search_event(item: Mapping[str, Any]) -> bool: +def _codex_is_empty_web_search_event(item: Mapping[object, object]) -> bool: return _string_field(item, "type") == "web_search" def _first_display_value( - record: Mapping[str, Any], + record: Mapping[object, object], keys: tuple[str, ...], limits: LogPresentationLimits, ) -> str | None: @@ -364,7 +372,7 @@ def _first_display_value( def _display_field_value( - value: Any, + value: object, limits: LogPresentationLimits, ) -> str | None: if value is None: @@ -377,7 +385,10 @@ def _display_field_value( return compact_json_line(value, limits) -def _string_field(record: Mapping[str, Any], key: str) -> str | None: +def _string_field( + record: Mapping[object, object], + key: str, +) -> str | None: value = record.get(key) if isinstance(value, str) and value.strip(): return value.strip() diff --git a/src/crewplane/observability/log_presentation/sanitize.py b/src/crewplane/observability/log_presentation/sanitize.py index da90453..c0e3007 100644 --- a/src/crewplane/observability/log_presentation/sanitize.py +++ b/src/crewplane/observability/log_presentation/sanitize.py @@ -2,7 +2,6 @@ import re from collections.abc import Mapping -from typing import Any from .limits import DEFAULT_LIMITS, LogPresentationLimits @@ -60,7 +59,7 @@ def sanitize_lines( return tuple(sanitize_line(value, limits) for value in values if value.strip()) -def redact_json_value(value: Any) -> Any: +def redact_json_value(value: object) -> object: if isinstance(value, Mapping): return { str(key): ( diff --git a/src/crewplane/observability/log_presentation/tail.py b/src/crewplane/observability/log_presentation/tail.py index 9d884c0..e4a8bd1 100644 --- a/src/crewplane/observability/log_presentation/tail.py +++ b/src/crewplane/observability/log_presentation/tail.py @@ -4,16 +4,11 @@ from pathlib import Path from typing import BinaryIO +from crewplane.observability.log_headers import provider_log_body_start + from .limits import DEFAULT_LIMITS, LogPresentationLimits from .models import LogReadResult -_INITIAL_HEADER_PREFIXES = ( - "started_at:", - "cli_executable:", - "model:", - "output_file:", -) -_OPTIONAL_REASONING_PREFIX = "requested_reasoning:" _RETRY_MARKER = b"\n---\nretry_attempt:" @@ -125,25 +120,4 @@ def find_initial_body_start( except OSError: return 0 - header_lines: list[str] = [] - body_start = 0 - for raw_line in head.splitlines(keepends=True): - stripped = raw_line.rstrip(b"\r\n") - body_start += len(raw_line) - if stripped == b"---": - break - if stripped: - header_lines.append(stripped.decode("utf-8", errors="replace").strip()) - else: - return 0 - - if len(header_lines) == len(_INITIAL_HEADER_PREFIXES) + 1 and header_lines[ - 3 - ].startswith(_OPTIONAL_REASONING_PREFIX): - header_lines.pop(3) - if len(header_lines) != len(_INITIAL_HEADER_PREFIXES): - return 0 - for line, prefix in zip(header_lines, _INITIAL_HEADER_PREFIXES, strict=True): - if not line.startswith(prefix): - return 0 - return body_start + return provider_log_body_start(head) diff --git a/src/crewplane/observability/run_summary/builder.py b/src/crewplane/observability/run_summary/builder.py index a3e4a6f..5721484 100644 --- a/src/crewplane/observability/run_summary/builder.py +++ b/src/crewplane/observability/run_summary/builder.py @@ -1,6 +1,6 @@ from __future__ import annotations -from crewplane.architecture.ports import ArtifactStorePort +from crewplane.architecture.ports import RunSummaryArtifactReaderPort from crewplane.observability.events import ExecutionEvent from crewplane.observability.timing import format_elapsed_seconds from crewplane.observability.types import DashboardSnapshot, RunResult @@ -22,7 +22,7 @@ def build_run_summary( - artifact_store: ArtifactStorePort, + artifact_store: RunSummaryArtifactReaderPort, snapshot: DashboardSnapshot | None, events: list[ExecutionEvent], result: RunResult, @@ -32,6 +32,8 @@ def build_run_summary( summary_facts: RunSummaryFacts | None = None, token_aggregates: ProviderTokenAggregates | None = None, ) -> RunSummary: + """Build a durable run summary from artifacts, events, and the latest snapshot.""" + workflow_name = ( snapshot.state.workflow_name if snapshot is not None else fallback_workflow_name ) @@ -77,7 +79,7 @@ def run_summary_facts_from_events(events: list[ExecutionEvent]) -> RunSummaryFac def summary_issues( - artifact_store: ArtifactStorePort, + artifact_store: RunSummaryArtifactReaderPort, events: list[ExecutionEvent], dropped_event_count: int, ) -> tuple[IssueSummary, ...]: @@ -130,7 +132,7 @@ def node_counts(snapshot: DashboardSnapshot | None) -> NodeCounts: def node_outcome_summaries( - artifact_store: ArtifactStorePort, + artifact_store: RunSummaryArtifactReaderPort, snapshot: DashboardSnapshot | None, ) -> tuple[NodeOutcomeSummary, ...]: if snapshot is None: diff --git a/src/crewplane/observability/run_summary/markdown.py b/src/crewplane/observability/run_summary/markdown.py index ef7f795..b720899 100644 --- a/src/crewplane/observability/run_summary/markdown.py +++ b/src/crewplane/observability/run_summary/markdown.py @@ -13,6 +13,8 @@ def render_run_summary_markdown(summary: RunSummary) -> str: + """Render a complete persisted run summary as Markdown.""" + lines = [ "# Run Summary\n\n", f"- Workflow: {summary.workflow_name}\n", diff --git a/src/crewplane/observability/run_summary/models.py b/src/crewplane/observability/run_summary/models.py index 1fbb210..3f494cf 100644 --- a/src/crewplane/observability/run_summary/models.py +++ b/src/crewplane/observability/run_summary/models.py @@ -16,6 +16,8 @@ @dataclass(frozen=True) class NodeCounts: + """Counts of workflow nodes grouped by runtime status.""" + pending: int running: int succeeded: int @@ -25,6 +27,8 @@ class NodeCounts: @dataclass(frozen=True) class SpendTotals: + """Run-wide invocation, token-estimate, and configured-cost totals.""" + terminal_invocations: int total_attempts: int cli_captured_invocations: int @@ -56,6 +60,8 @@ def terminal_value(self) -> str: @dataclass(frozen=True) class ProviderUsageRollup: + """Spend observability totals grouped by provider.""" + provider: str terminal_invocations: int total_attempts: int @@ -70,6 +76,8 @@ class ProviderUsageRollup: @dataclass(frozen=True) class ProviderTokenAggregate: + """Exact provider-reported token totals for one provider scope.""" + provider: str | None report_count: int input: int | None = None @@ -82,6 +90,8 @@ class ProviderTokenAggregate: @dataclass(frozen=True) class ProviderTokenAggregates: + """Overall and per-provider exact token aggregates.""" + overall: ProviderTokenAggregate | None = None providers: tuple[ProviderTokenAggregate, ...] = () @@ -101,6 +111,8 @@ class UsageRollupValues: @dataclass(frozen=True) class InvocationUsageSummary: + """Captured usage, extraction, cost, and failure facts for one invocation.""" + provider: str node_id: str | None task_id: str | None @@ -133,6 +145,8 @@ def __post_init__(self) -> None: @dataclass(frozen=True) class NodeOutcomeSummary: + """Terminal display facts and result path for one workflow node.""" + node_id: str status: str duration_label: str @@ -141,6 +155,8 @@ class NodeOutcomeSummary: @dataclass(frozen=True) class IssueSummary: + """One warning or error retained in a run summary.""" + level: str timestamp_utc: str message: str @@ -148,6 +164,8 @@ class IssueSummary: @dataclass(frozen=True) class ArtifactReferenceSummary: + """Output and log references for one recorded invocation.""" + node_id: str task_id: str audit_round_num: int | None @@ -158,6 +176,8 @@ class ArtifactReferenceSummary: @dataclass(frozen=True) class WorkspacePlanSummary: + """Persisted workspace plan facts relevant to run observability.""" + worktree_contract_mode: str | None worktree_contract_schema_version: str | None source_commit: str | None @@ -177,6 +197,8 @@ class WorkspacePlanSummary: @dataclass(frozen=True) class WorkspaceInvocationSourceSummary: + """Source lineage selected for a workspace invocation.""" + kind: str | None = None node_id: str | None = None commit: str | None = None @@ -187,6 +209,8 @@ class WorkspaceInvocationSourceSummary: @dataclass(frozen=True) class WorkspaceInvocationExecutionSummary: + """Materialization paths, size, and timing for a workspace invocation.""" + cache_root: str | None = None workspace_path: str | None = None checkout_root: str | None = None @@ -197,6 +221,8 @@ class WorkspaceInvocationExecutionSummary: @dataclass(frozen=True) class WorkspaceInvocationSetupSummary: + """Setup-profile outcome for a workspace invocation.""" + profile_name: str | None = None status: str | None = None duration_seconds: float | None = None @@ -208,6 +234,8 @@ class WorkspaceInvocationSetupSummary: @dataclass(frozen=True) class WorkspaceInvocationReuseSummary: + """Checkout reuse decision and fallback details for an invocation.""" + strategy: str | None = None reused: bool | None = None fallback: bool | None = None @@ -218,6 +246,8 @@ class WorkspaceInvocationReuseSummary: @dataclass(frozen=True) class WorkspaceInvocationBranchExportSummary: + """Post-run branch-export outcome for a workspace invocation.""" + status: str | None = None operation: str | None = None branch_name: str | None = None @@ -228,6 +258,8 @@ class WorkspaceInvocationBranchExportSummary: @dataclass(frozen=True) class WorkspaceInvocationSummary: + """Complete observable workspace lifecycle for one provider invocation.""" + node_id: str | None task_id: str | None audit_round_num: int | None @@ -276,12 +308,16 @@ class WorkspaceInvocationSummary: @dataclass(frozen=True) class WorkspaceRunSummary: + """Workspace plan and invocation details retained for a run.""" + plan: WorkspacePlanSummary | None invocations: tuple[WorkspaceInvocationSummary, ...] @dataclass(frozen=True) class RunSummary: + """Durable human-facing summary of one workflow run.""" + workflow_name: str run_id: str workflow_status: str diff --git a/src/crewplane/observability/run_summary/spend.py b/src/crewplane/observability/run_summary/spend.py index bbafa38..b116065 100644 --- a/src/crewplane/observability/run_summary/spend.py +++ b/src/crewplane/observability/run_summary/spend.py @@ -2,14 +2,16 @@ from collections.abc import Iterable from dataclasses import dataclass, field -from typing import TypedDict +from typing import TypedDict, TypeGuard from crewplane.architecture.contracts import ( AggregateCostConfidence, EventType, InvocationCostConfidence, InvocationEventType, + ProviderUsageStatus, ) +from crewplane.core.value_checks import is_nonnegative_int from crewplane.observability.events import ExecutionEvent, InvocationEventPayload from .formatting import format_cost, format_count @@ -72,12 +74,9 @@ def record(self, payload: InvocationEventPayload) -> None: for bucket in TOKEN_BUCKETS: value = provider_tokens.get(bucket) current = self.values[bucket] - valid_value = ( - isinstance(value, int) and not isinstance(value, bool) and value >= 0 - ) if not had_reports: - self.values[bucket] = value if valid_value else None - elif current is None or not valid_value: + self.values[bucket] = value if is_nonnegative_int(value) else None + elif current is None or not is_nonnegative_int(value): self.values[bucket] = None else: self.values[bucket] = current + value @@ -234,14 +233,16 @@ def invocation_usage_summary_from_event( attempt_count=payload.attempt_count, cli_captured=bool(payload.cli_captured), output_extraction_status=payload.output_extraction_status or "missing", - provider_usage_status=payload.provider_usage_status or "none", + provider_usage_status=_provider_usage_status(payload.provider_usage_status), provider_usage_report_count=payload.provider_usage_report_count, provider_tokens=dict(payload.provider_tokens or {}), visible_estimate_tokens=payload.visible_estimate_tokens, visible_estimate_method=payload.visible_estimate_method, visible_estimate_is_lower_bound=bool(payload.visible_estimate_is_lower_bound), configured_cost_usd=payload.configured_cost_usd, - invocation_cost_confidence=payload.invocation_cost_confidence or "none", + invocation_cost_confidence=_invocation_cost_confidence( + payload.invocation_cost_confidence + ), usage_parse_error=payload.usage_parse_error, failure_kind=payload.failure_kind, failure_phase=payload.failure_phase, @@ -250,6 +251,33 @@ def invocation_usage_summary_from_event( ) +def _provider_usage_status(value: object) -> ProviderUsageStatus: + if _is_provider_usage_status(value): + return value + return "none" + + +def _is_provider_usage_status(value: object) -> TypeGuard[ProviderUsageStatus]: + return isinstance(value, str) and value in { + "full", + "partial", + "none", + "malformed", + } + + +def _invocation_cost_confidence(value: object) -> InvocationCostConfidence: + if _is_invocation_cost_confidence(value): + return value + return "none" + + +def _is_invocation_cost_confidence( + value: object, +) -> TypeGuard[InvocationCostConfidence]: + return isinstance(value, str) and value in {"full", "partial", "none"} + + def invocation_payload(event: ExecutionEvent) -> InvocationEventPayload: if not isinstance(event.payload, InvocationEventPayload): raise TypeError( diff --git a/src/crewplane/observability/run_summary/terminal.py b/src/crewplane/observability/run_summary/terminal.py index e2cd1af..d02ae3a 100644 --- a/src/crewplane/observability/run_summary/terminal.py +++ b/src/crewplane/observability/run_summary/terminal.py @@ -11,6 +11,8 @@ def render_run_summary_terminal(summary: RunSummary) -> str: + """Render the concise terminal form of a run summary.""" + lines = [ "Run Summary", f" Workflow: {summary.workflow_name}", diff --git a/src/crewplane/observability/run_summary/workspace.py b/src/crewplane/observability/run_summary/workspace.py index 7024e5f..9ecc523 100644 --- a/src/crewplane/observability/run_summary/workspace.py +++ b/src/crewplane/observability/run_summary/workspace.py @@ -3,7 +3,7 @@ from dataclasses import replace from pathlib import Path -from crewplane.architecture.ports import ArtifactStorePort +from crewplane.architecture.ports import RunSummaryArtifactReaderPort from crewplane.observability.events import EventType, ExecutionEvent from crewplane.observability.events.payloads import WorkspaceEventPayload @@ -58,7 +58,7 @@ def workspace_invocation_summary_from_event( def build_workspace_run_summary( - artifact_store: ArtifactStorePort, + artifact_store: RunSummaryArtifactReaderPort, event_invocations: tuple[WorkspaceInvocationSummary, ...], ) -> WorkspaceRunSummary | None: plan = workspace_plan_summary(artifact_store.stages_dir) diff --git a/src/crewplane/observability/tmux/client.py b/src/crewplane/observability/tmux/client.py index 1bd9ff2..d337841 100644 --- a/src/crewplane/observability/tmux/client.py +++ b/src/crewplane/observability/tmux/client.py @@ -1,10 +1,11 @@ from __future__ import annotations import subprocess -import sys from collections.abc import Callable from typing import Protocol +from .warnings import dispatch_tmux_warning + DEFAULT_TMUX_COMMAND_TIMEOUT_SECONDS = 1.0 TMUX_TIMEOUT_RETURN_CODE = 124 TMUX_TIMEOUT_STDERR = "tmux command timed out" @@ -126,13 +127,7 @@ def _warn_tmux_timeout(self, command: list[str]) -> None: ) def _warn(self, message: str) -> None: - if self._warning_sink is not None: - try: - self._warning_sink(message) - except Exception: - return - return - print(f"WARN: {message}", file=sys.stderr) + dispatch_tmux_warning(self._warning_sink, message) def tmux_result_timed_out(result: subprocess.CompletedProcess[str]) -> bool: diff --git a/src/crewplane/observability/tmux/compact.py b/src/crewplane/observability/tmux/compact.py index 2e47633..0f2890c 100644 --- a/src/crewplane/observability/tmux/compact.py +++ b/src/crewplane/observability/tmux/compact.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from collections.abc import Callable from threading import Event, Thread from typing import cast @@ -26,6 +25,7 @@ TmuxClientFactory, TmuxCompactSessionLifecycle, ) +from crewplane.observability.tmux.warnings import dispatch_tmux_warning from crewplane.observability.tmux.window import TmuxCompactWindowOptions from crewplane.observability.types import ( DashboardSnapshot, @@ -185,13 +185,7 @@ def _request_stop(self) -> None: self._stop_event.set() def _warn(self, message: str) -> None: - if self._warning_sink is not None: - try: - self._warning_sink(message) - except Exception: - return - return - print(f"WARN: {message}", file=sys.stderr) + dispatch_tmux_warning(self._warning_sink, message) def _clock_kwargs( diff --git a/src/crewplane/observability/tmux/log_tail.py b/src/crewplane/observability/tmux/log_tail.py index 16e421e..1ce5af6 100644 --- a/src/crewplane/observability/tmux/log_tail.py +++ b/src/crewplane/observability/tmux/log_tail.py @@ -4,13 +4,7 @@ from dataclasses import dataclass from pathlib import Path -_LOG_HEADER_PREFIXES = ( - "started_at:", - "cli_executable:", - "model:", - "output_file:", -) -_OPTIONAL_REASONING_PREFIX = "requested_reasoning:" +from crewplane.observability.log_headers import provider_log_body_start @dataclass(frozen=True) @@ -144,27 +138,4 @@ def _find_log_body_start(log_path: Path, file_size: int) -> int: return 0 with log_path.open("rb") as handle: head = handle.read(read_size) - header_lines: list[str] = [] - body_start = 0 - for raw_line in head.splitlines(keepends=True): - stripped = raw_line.rstrip(b"\r\n") - body_start += len(raw_line) - if stripped == b"---": - break - if stripped: - header_lines.append(stripped.decode("utf-8", errors="replace").strip()) - else: - return 0 - - if len(header_lines) == len(_LOG_HEADER_PREFIXES) + 1 and header_lines[ - 3 - ].startswith(_OPTIONAL_REASONING_PREFIX): - header_lines.pop(3) - if len(header_lines) != len(_LOG_HEADER_PREFIXES): - return 0 - if any( - not line.startswith(prefix) - for line, prefix in zip(header_lines, _LOG_HEADER_PREFIXES, strict=True) - ): - return 0 - return body_start + return provider_log_body_start(head) diff --git a/src/crewplane/observability/tmux/session_lifecycle.py b/src/crewplane/observability/tmux/session_lifecycle.py index 86e507f..21acff7 100644 --- a/src/crewplane/observability/tmux/session_lifecycle.py +++ b/src/crewplane/observability/tmux/session_lifecycle.py @@ -3,7 +3,6 @@ import os import shutil import subprocess -import sys import tempfile from collections.abc import Callable from dataclasses import dataclass @@ -27,6 +26,7 @@ TmuxSessionIdentity, TmuxSessionTargets, ) +from crewplane.observability.tmux.warnings import dispatch_tmux_warning from crewplane.observability.types import RunContext TmuxClientFactory = Callable[[str | None], TmuxSessionClient] @@ -324,13 +324,7 @@ def _cleanup_lease(self, lease: RuntimeDirectoryLease, force: bool) -> None: self._warn(f"tmux compact temp cleanup failed: {exc}") def _warn(self, message: str) -> None: - if self._warning_sink is not None: - try: - self._warning_sink(message) - except Exception: - return - return - print(f"WARN: {message}", file=sys.stderr) + dispatch_tmux_warning(self._warning_sink, message) def _terminate_attach_process(process: subprocess.Popen[str]) -> None: diff --git a/src/crewplane/observability/tmux/warnings.py b/src/crewplane/observability/tmux/warnings.py new file mode 100644 index 0000000..1de14e3 --- /dev/null +++ b/src/crewplane/observability/tmux/warnings.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import sys +from collections.abc import Callable + + +def dispatch_tmux_warning( + warning_sink: Callable[[str], None] | None, + message: str, +) -> None: + """Send a best-effort tmux warning to the configured sink or stderr.""" + + if warning_sink is not None: + try: + warning_sink(message) + except Exception: + return + return + print(f"WARN: {message}", file=sys.stderr) diff --git a/src/crewplane/runtime/agent/failures/evidence.py b/src/crewplane/runtime/agent/failures/evidence.py index 7704fa6..19ffea2 100644 --- a/src/crewplane/runtime/agent/failures/evidence.py +++ b/src/crewplane/runtime/agent/failures/evidence.py @@ -2,7 +2,7 @@ import json from collections import deque -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from itertools import chain from crewplane.architecture.contracts import CommandResult, ProviderKind @@ -147,7 +147,7 @@ def _json_failure_message(payload: dict[str, object]) -> str | None: return _first_string(payload, ("message", "detail", "result", "status")) -def _first_string(payload: dict[object, object], keys: tuple[str, ...]) -> str | None: +def _first_string(payload: Mapping[str, object], keys: tuple[str, ...]) -> str | None: for key in keys: value = payload.get(key) if isinstance(value, str) and value.strip(): diff --git a/src/crewplane/runtime/agent/invocation/command.py b/src/crewplane/runtime/agent/invocation/command.py index 969d448..8c829c5 100644 --- a/src/crewplane/runtime/agent/invocation/command.py +++ b/src/crewplane/runtime/agent/invocation/command.py @@ -6,16 +6,18 @@ from collections.abc import Awaitable from dataclasses import replace from pathlib import Path -from typing import BinaryIO +from typing import BinaryIO, cast from crewplane.architecture.contracts import ( ChildProcessEnvironment, CommandResult, CommandRunner, InvocationContext, + InvocationDiagnosticSink, InvocationPlan, InvocationProcessEvent, ) +from crewplane.core.platform import supports_posix_process_groups from ..process.runner import ( build_retry_log_header, @@ -38,7 +40,7 @@ def open_log_handle( return None log_file.parent.mkdir(parents=True, exist_ok=True) mode = "ab" if append else "wb" - handle = log_file.open(mode) + handle = cast(BinaryIO, log_file.open(mode)) if header_bytes: handle.write(header_bytes) handle.flush() @@ -64,7 +66,7 @@ async def run_command_once( invocation_context.diagnostics if invocation_context is not None else None ) try: - process_kwargs = {"start_new_session": True} if os.name == "posix" else {} + start_new_session = supports_posix_process_groups() process = await asyncio.create_subprocess_exec( *cmd, stdin=asyncio.subprocess.PIPE if stdin_data else asyncio.subprocess.DEVNULL, @@ -72,10 +74,10 @@ async def run_command_once( stderr=asyncio.subprocess.PIPE, cwd=cwd, env=_child_process_env(child_environment), - **process_kwargs, + start_new_session=start_new_session, ) # start_new_session=True makes the child both session and process-group leader. - process_group_id = process.pid if os.name == "posix" else None + process_group_id = process.pid if start_new_session else None record_workspace_child_environment_applied( invocation_context, child_environment, @@ -99,22 +101,32 @@ async def run_command_once( idle_timeout_seconds, ) except FileNotFoundError as exc: - raise RuntimeError(f"CLI executable not found: {cmd[0]}") from exc + await _cleanup_failed_command( + process, + process_group_id, + output_capture, + diagnostic_sink, + ) + if process is None: + raise RuntimeError(f"CLI executable not found: {cmd[0]}") from exc + raise RuntimeError(f"Execution error: {exc}") from exc except asyncio.CancelledError: - if process is not None: - await reap_failed_process(process, process_group_id, diagnostic_sink) - if output_capture is not None: - output_capture.cleanup() + await _cleanup_failed_command( + process, + process_group_id, + output_capture, + diagnostic_sink, + ) raise except Exception as exc: - if process is not None: - await reap_failed_process(process, process_group_id, diagnostic_sink) + await _cleanup_failed_command( + process, + process_group_id, + output_capture, + diagnostic_sink, + ) if isinstance(exc, RuntimeError): - if output_capture is not None: - output_capture.cleanup() raise - if output_capture is not None: - output_capture.cleanup() raise RuntimeError(f"Execution error: {exc}") from exc finally: active_exception = sys.exception() @@ -139,6 +151,18 @@ async def run_command_once( ) +async def _cleanup_failed_command( + process: asyncio.subprocess.Process | None, + process_group_id: int | None, + output_capture: ProcessOutputCapture | None, + diagnostic_sink: InvocationDiagnosticSink | None, +) -> None: + if process is not None: + await reap_failed_process(process, process_group_id, diagnostic_sink) + if output_capture is not None: + output_capture.cleanup() + + def build_invocation_runtime(plan: InvocationPlan) -> InvocationCommandRuntime: return InvocationCommandRuntime( failure_profile=plan.failure_profile, diff --git a/src/crewplane/runtime/agent/invocation/state.py b/src/crewplane/runtime/agent/invocation/state.py index f669412..5686815 100644 --- a/src/crewplane/runtime/agent/invocation/state.py +++ b/src/crewplane/runtime/agent/invocation/state.py @@ -8,13 +8,14 @@ FailureClassificationProfile, InvocationLogLevel, OneShotFailureRetryPolicy, + OutputExtractionStatus, OutputExtractor, QuotaParserProfile, RuntimeLogValue, UsageDecoder, ) -from ..usage import InvocationUsageAccumulator, OutputExtractionStatus +from ..usage import InvocationUsageAccumulator @dataclass(frozen=True) diff --git a/src/crewplane/runtime/agent/process/stream_capture.py b/src/crewplane/runtime/agent/process/stream_capture.py index c7a8941..76ada7a 100644 --- a/src/crewplane/runtime/agent/process/stream_capture.py +++ b/src/crewplane/runtime/agent/process/stream_capture.py @@ -111,7 +111,7 @@ class ProcessOutputCapture: stdout: ProcessStreamCapture stderr: ProcessStreamCapture - def __iter__(self): + def __iter__(self) -> Iterator[bytes]: return iter((self.stdout.tail_bytes, self.stderr.tail_bytes)) def cleanup(self) -> None: diff --git a/src/crewplane/runtime/agent/process/streams.py b/src/crewplane/runtime/agent/process/streams.py index f08d5bd..1aff29b 100644 --- a/src/crewplane/runtime/agent/process/streams.py +++ b/src/crewplane/runtime/agent/process/streams.py @@ -121,6 +121,8 @@ async def collect_process_output( ) ) if log_handle is not None and log_queue is not None: + if writer_status is None: + raise RuntimeError("Log writer status was not initialized.") task_group.create_task( drain_log_queue(log_handle, log_queue, writer_status) ) @@ -176,6 +178,8 @@ async def capture_process_streams( process_group_id: int | None = None, idle_timeout_seconds: float | None = None, ) -> None: + if process.stdout is None or process.stderr is None: + raise RuntimeError("Failed to capture process streams.") activity = ProcessActivity() stdout_task = asyncio.create_task( pipe_stream(process.stdout, log_queue, b"", stdout_capture, activity) diff --git a/src/crewplane/runtime/agent/workspace_environment.py b/src/crewplane/runtime/agent/workspace_environment.py index 9e086e7..80ac166 100644 --- a/src/crewplane/runtime/agent/workspace_environment.py +++ b/src/crewplane/runtime/agent/workspace_environment.py @@ -17,9 +17,9 @@ def prepare_workspace_child_environment( invocation_context: InvocationContext | None, child_environment: ChildProcessEnvironment | None, ) -> tuple[InvocationContext | None, ChildProcessEnvironment | None]: - workspace = invocation_context.workspace if invocation_context is not None else None - if workspace is None: + if invocation_context is None or invocation_context.workspace is None: return invocation_context, child_environment + workspace = invocation_context.workspace if not workspace.child_environment_required and child_environment is None: return invocation_context, None diff --git a/src/crewplane/runtime/execution/activity/events.py b/src/crewplane/runtime/execution/activity/events.py index 12eb207..2346ebe 100644 --- a/src/crewplane/runtime/execution/activity/events.py +++ b/src/crewplane/runtime/execution/activity/events.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass, replace from pathlib import Path @@ -229,7 +230,7 @@ def emit_runtime_log( context: RuntimeEventContext | None = None, duration_ms: int | None = None, error: str | None = None, - attributes: dict[str, RuntimeLogValue] | None = None, + attributes: Mapping[str, RuntimeLogValue] | None = None, ) -> None: if telemetry is None: return diff --git a/src/crewplane/runtime/execution/fragment_assembler.py b/src/crewplane/runtime/execution/fragment_assembler.py index 7b1cf90..37196aa 100644 --- a/src/crewplane/runtime/execution/fragment_assembler.py +++ b/src/crewplane/runtime/execution/fragment_assembler.py @@ -22,9 +22,9 @@ from .workspace_files import ( ResolvedWorkspaceFile, - WorkspaceCandidateSourceContext, resolve_workspace_file, ) +from .workspace_files.source_resolution import WorkspaceCandidateSourceContext OUTPUT_ARTIFACT_KEYS = {"output", "output_path", "output_size", "output_sha256"} FINDINGS_ARTIFACT_KEYS = { diff --git a/src/crewplane/runtime/execution/prompt_budgeting.py b/src/crewplane/runtime/execution/prompt_budgeting.py index a6f28d5..9c08aa7 100644 --- a/src/crewplane/runtime/execution/prompt_budgeting.py +++ b/src/crewplane/runtime/execution/prompt_budgeting.py @@ -2,6 +2,7 @@ from dataclasses import dataclass +from crewplane.architecture.contracts import RuntimeLogValue from crewplane.architecture.ports import ArtifactStorePort from crewplane.core.preflight.models import PreflightExecutionNode from crewplane.core.workflow.keywords import ProviderRole @@ -15,7 +16,8 @@ inspect_runtime_locators, ) from .runtime_context import CompiledRuntimeContext -from .workspace_files import ResolvedWorkspaceFile, WorkspaceCandidateSourceContext +from .workspace_files import ResolvedWorkspaceFile +from .workspace_files.source_resolution import WorkspaceCandidateSourceContext class PromptBudgetExceededError(NodeExecutionError): @@ -33,7 +35,7 @@ class PromptBudgetInspection: display_name: str char_count: int shorten_advice: str - warning_attributes: dict[str, object] + warning_attributes: dict[str, RuntimeLogValue] def compiled_token_budget(node: PreflightExecutionNode) -> dict[str, int | None]: @@ -89,19 +91,21 @@ def resolve_prompt_with_output_budget_details( role, output, ) - for inspection in inspections: + for locator_inspection in inspections: _enforce_prompt_budget( node, PromptBudgetInspection( - display_name=f"'{inspection.node_id}.{inspection.artifact_name}'", - char_count=inspection.char_count, + display_name=( + f"'{locator_inspection.node_id}.{locator_inspection.artifact_name}'" + ), + char_count=locator_inspection.char_count, shorten_advice=( "Shorten the upstream artifact or raise the threshold " "intentionally." ), warning_attributes={ - "upstream_node_id": inspection.node_id, - "upstream_artifact_name": inspection.artifact_name, + "upstream_node_id": locator_inspection.node_id, + "upstream_artifact_name": locator_inspection.artifact_name, }, ), thresholds, diff --git a/src/crewplane/runtime/execution/provider_call/events.py b/src/crewplane/runtime/execution/provider_call/events.py index 70c0c12..d47b60f 100644 --- a/src/crewplane/runtime/execution/provider_call/events.py +++ b/src/crewplane/runtime/execution/provider_call/events.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from pathlib import Path from crewplane.architecture.contracts import ( @@ -8,6 +8,7 @@ InvocationContext, InvocationDiagnostic, InvocationProcessEvent, + RuntimeLogValue, ) from crewplane.architecture.ports import ( ArtifactStorePort, @@ -153,7 +154,7 @@ def emit_artifact_capture_event( invocation_metadata: InvocationMetadata | None, operation: str, message: str, - attributes: dict[str, object] | None = None, + attributes: Mapping[str, RuntimeLogValue] | None = None, ) -> None: if telemetry is None or invocation_metadata is None: return diff --git a/src/crewplane/runtime/execution/provider_call/workspace.py b/src/crewplane/runtime/execution/provider_call/workspace.py index bd2a78d..66b0590 100644 --- a/src/crewplane/runtime/execution/provider_call/workspace.py +++ b/src/crewplane/runtime/execution/provider_call/workspace.py @@ -12,7 +12,7 @@ from crewplane.runtime.workspace.cleanup_notes import note_cleanup_failure from crewplane.runtime.workspace.setup import WorkspaceSetupCancellation -from ..runtime_context import DeferredAsyncCleanupRegistry +from ..deferred_cleanup import DeferredAsyncCleanupRegistry PREPARATION_CANCELLATION_TIMEOUT_SECONDS = 0.5 PREPARATION_CANCELLATION_MESSAGE = ( diff --git a/src/crewplane/runtime/execution/resume.py b/src/crewplane/runtime/execution/resume.py index c0203f7..6ab9de9 100644 --- a/src/crewplane/runtime/execution/resume.py +++ b/src/crewplane/runtime/execution/resume.py @@ -13,6 +13,7 @@ from crewplane.core.execution_state import ( RUN_STATE_SCHEMA_VERSION, ArtifactDescriptor, + ArtifactKind, NodeState, ) from crewplane.core.preflight.models import ( @@ -92,7 +93,7 @@ def _generated_file_descriptors( ] -def _descriptor(kind: str, root: Path, path: Path) -> ArtifactDescriptor: +def _descriptor(kind: ArtifactKind, root: Path, path: Path) -> ArtifactDescriptor: return ArtifactDescriptor( kind=kind, relative_path=path.relative_to(root).as_posix(), diff --git a/src/crewplane/runtime/execution/review_loop/audit_round.py b/src/crewplane/runtime/execution/review_loop/audit_round.py index 224a956..73e8a57 100644 --- a/src/crewplane/runtime/execution/review_loop/audit_round.py +++ b/src/crewplane/runtime/execution/review_loop/audit_round.py @@ -11,7 +11,7 @@ should_print_console, ) from ..consensus import check_consensus -from ..workspace_files import WorkspaceCandidateSourceContext +from ..workspace_files.source_resolution import WorkspaceCandidateSourceContext from .executor_round import run_executor_round from .prompts import build_review_context from .reviewer_round import run_reviewer_round diff --git a/src/crewplane/runtime/execution/review_loop/drift/recovery.py b/src/crewplane/runtime/execution/review_loop/drift/recovery.py index 4e80753..61f1331 100644 --- a/src/crewplane/runtime/execution/review_loop/drift/recovery.py +++ b/src/crewplane/runtime/execution/review_loop/drift/recovery.py @@ -6,6 +6,7 @@ import tempfile from dataclasses import dataclass from pathlib import Path +from typing import BinaryIO, cast from crewplane.architecture.safe_files import ensure_contained_directory from crewplane.runtime.execution.publication_registry import ( @@ -193,7 +194,10 @@ def _atomic_restore_registered( _remove_existing_directory(path) with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as temporary: temporary_path = Path(temporary.name) - if not publications.copy_recovery_payload_to(path, temporary): + if not publications.copy_recovery_payload_to( + path, + cast(BinaryIO, temporary), + ): return False temporary.flush() os.fsync(temporary.fileno()) diff --git a/src/crewplane/runtime/execution/review_loop/executor_round.py b/src/crewplane/runtime/execution/review_loop/executor_round.py index f4f1636..6ec00b7 100644 --- a/src/crewplane/runtime/execution/review_loop/executor_round.py +++ b/src/crewplane/runtime/execution/review_loop/executor_round.py @@ -10,7 +10,7 @@ from ..common import ProviderCallDisplay, resolve_prompt_with_output_budget_details from ..provider_call import ProviderOutputPolicy, read_bound_invocation_output -from ..workspace_files import WorkspaceCandidateSourceContext +from ..workspace_files.source_resolution import WorkspaceCandidateSourceContext from .drift import run_provider_call_with_drift_guard from .prompts import ( build_executor_prompt, diff --git a/src/crewplane/runtime/execution/review_loop/orchestration.py b/src/crewplane/runtime/execution/review_loop/orchestration.py index 513bee1..617ed91 100644 --- a/src/crewplane/runtime/execution/review_loop/orchestration.py +++ b/src/crewplane/runtime/execution/review_loop/orchestration.py @@ -25,7 +25,8 @@ ) from ..provider_call import publish_invocation_output from ..reviews.consensus import check_consensus -from ..workspace_files import ResolvedWorkspaceFile, WorkspaceCandidateSourceContext +from ..workspace_files import ResolvedWorkspaceFile +from ..workspace_files.source_resolution import WorkspaceCandidateSourceContext from .policy import ( audit_round_context, audit_round_dir, diff --git a/src/crewplane/runtime/execution/review_loop/state.py b/src/crewplane/runtime/execution/review_loop/state.py index 7959b4d..e26cac9 100644 --- a/src/crewplane/runtime/execution/review_loop/state.py +++ b/src/crewplane/runtime/execution/review_loop/state.py @@ -334,13 +334,11 @@ def build_review_loop_status_payload( def _selected_round_num(progress: ReviewLoopProgress) -> int: - artifacts = [ - *(progress.latest_executor_outputs or []), - *progress.latest_reviewer_outputs, - ] - if not artifacts: - return 0 - return artifacts[0].round_num + if progress.latest_executor_outputs: + return progress.latest_executor_outputs[0].round_num + if progress.latest_reviewer_outputs: + return progress.latest_reviewer_outputs[0].round_num + return 0 def persist_review_loop_status( diff --git a/src/crewplane/runtime/execution/review_loop/validation.py b/src/crewplane/runtime/execution/review_loop/validation.py index 13a5d36..d9c97da 100644 --- a/src/crewplane/runtime/execution/review_loop/validation.py +++ b/src/crewplane/runtime/execution/review_loop/validation.py @@ -4,6 +4,7 @@ import re from pathlib import Path +from crewplane.architecture.contracts import RuntimeLogValue from crewplane.artifacts.failure_artifacts import ( is_synthetic_invocation_failure, ) @@ -166,7 +167,7 @@ def emit_review_evaluation_warnings( round_num=round_num, output_file=output_file, ) - attributes = { + attributes: dict[str, RuntimeLogValue] = { "approved": evaluation.approved, "evaluation_kind": evaluation.evaluation_kind, "had_leading_text": evaluation.had_leading_text, diff --git a/src/crewplane/runtime/execution/runtime_context.py b/src/crewplane/runtime/execution/runtime_context.py index b4dab72..5b5e6dc 100644 --- a/src/crewplane/runtime/execution/runtime_context.py +++ b/src/crewplane/runtime/execution/runtime_context.py @@ -2,6 +2,7 @@ from dataclasses import dataclass, field +from crewplane.architecture.contracts import JsonObject from crewplane.core.config import AgentConfig from crewplane.core.preflight.models import ( PreflightExecutionPlan, @@ -62,7 +63,9 @@ def agent_config_for_provider(self, provider: ProviderRecord) -> AgentConfig: if not isinstance(resolved_payload, dict): raise ValueError("Runtime agent config metadata must be a mapping.") runtime_agent = RuntimeAgentConfigSnapshot.model_validate(resolved_payload) - return AgentConfig(**runtime_agent_execution_payload(runtime_agent)) + return AgentConfig.model_validate( + runtime_agent_execution_payload(runtime_agent) + ) def _validate_provider_record(self, provider: ProviderRecord) -> None: expected_agent_signature = agent_config_signature_from_plan( @@ -132,7 +135,7 @@ def agent_config_signature_from_plan( def agent_config_payload_from_plan( plan: PreflightExecutionPlan, agent_config_key: str, -) -> dict[str, object]: +) -> JsonObject: agents = plan.runtime_config_snapshot.get("agents") if not isinstance(agents, dict): raise ValueError("Compiled plan is missing runtime agent config metadata.") @@ -142,7 +145,7 @@ def agent_config_payload_from_plan( "Compiled provider record references missing agent config " f"'{agent_config_key}'." ) - return payload + return dict(payload) def resolve_secret_config_values( diff --git a/src/crewplane/runtime/execution/sequential.py b/src/crewplane/runtime/execution/sequential.py index 0d1afc5..b0fdde6 100644 --- a/src/crewplane/runtime/execution/sequential.py +++ b/src/crewplane/runtime/execution/sequential.py @@ -16,7 +16,7 @@ run_provider_call, ) from .review_loop import execute_review_loop_stage -from .workspace_files import WorkspaceCandidateSourceContext +from .workspace_files.source_resolution import WorkspaceCandidateSourceContext DEFAULT_SINGLE_PROVIDER_ROUNDS = 1 diff --git a/src/crewplane/runtime/execution/workflow/state.py b/src/crewplane/runtime/execution/workflow/state.py index 76a0e60..89e54f1 100644 --- a/src/crewplane/runtime/execution/workflow/state.py +++ b/src/crewplane/runtime/execution/workflow/state.py @@ -53,7 +53,7 @@ def dependencies_by_node_from_plan( plan: PreflightExecutionPlan, ) -> dict[str, set[str]]: node_ids = {node.id for node in plan.nodes} - dependencies = {node.id: set() for node in plan.nodes} + dependencies: dict[str, set[str]] = {node.id: set() for node in plan.nodes} for edge in plan.dependency_graph: validate_dependency_edge(edge, node_ids) dependencies[edge.target_node].add(edge.source_node) diff --git a/src/crewplane/runtime/execution/workspace_files/__init__.py b/src/crewplane/runtime/execution/workspace_files/__init__.py index 6d6dbae..8c25019 100644 --- a/src/crewplane/runtime/execution/workspace_files/__init__.py +++ b/src/crewplane/runtime/execution/workspace_files/__init__.py @@ -21,6 +21,7 @@ valid_utf8_without_nul, ) from crewplane.core.workflow.keywords import ProviderRole +from crewplane.runtime.workspace.plan_nodes import workspace_plan_node from crewplane.runtime.workspace.state import RenderedWorkspaceFileDescriptor from crewplane.runtime.workspace.state_selection import ( latest_executor_lineage_state_path, @@ -249,7 +250,7 @@ def dynamic_locator_source_state_path( ): state_path = required_lineage_state_path( output, - _plan_node(plan, node.workspace_policy.source_node_id), + workspace_plan_node(plan, node.workspace_policy.source_node_id), ) else: raise RuntimeError( @@ -370,16 +371,6 @@ def latest_executor_workspace_state( return load_workspace_state(state_path) -def _plan_node( - plan: PreflightExecutionPlan, - node_id: str, -) -> PreflightExecutionNode: - for node in plan.nodes: - if node.id == node_id: - return node - raise RuntimeError(f"Workspace source references unknown node '{node_id}'.") - - def load_workspace_state(path: Path) -> dict[str, object]: try: payload = json.loads(path.read_text(encoding="utf-8")) diff --git a/src/crewplane/runtime/execution/workspace_files/source_resolution.py b/src/crewplane/runtime/execution/workspace_files/source_resolution.py index dea38ef..cc44eff 100644 --- a/src/crewplane/runtime/execution/workspace_files/source_resolution.py +++ b/src/crewplane/runtime/execution/workspace_files/source_resolution.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import Literal +from typing import Literal, TypeGuard from crewplane.architecture.ports import ArtifactStorePort from crewplane.core.preflight.models import ( @@ -13,6 +13,7 @@ WorkspaceFileTarget, ) from crewplane.core.workflow.keywords import ProviderRole +from crewplane.runtime.workspace.plan_nodes import workspace_plan_node from crewplane.runtime.workspace.state_selection import ( required_lineage_state_path, same_node_executor_state_path, @@ -108,7 +109,7 @@ def initial_pre_review_source( return load_source_ref_from_state( required_lineage_state_path( output, - _plan_node(plan, node.workspace_policy.source_node_id), + workspace_plan_node(plan, node.workspace_policy.source_node_id), ) ) @@ -123,7 +124,7 @@ def initial_pre_review_source( def is_initial_pre_review_context( context: WorkspaceCandidateSourceContext | None, -) -> bool: +) -> TypeGuard[WorkspaceCandidateSourceContext]: return ( context is not None and context.role_label == ProviderRole.REVIEWER @@ -143,15 +144,5 @@ def project_source_ref(plan: PreflightExecutionPlan) -> WorktreeSourceRef | None ) -def _plan_node( - plan: PreflightExecutionPlan, - node_id: str, -) -> PreflightExecutionNode: - for node in plan.nodes: - if node.id == node_id: - return node - raise RuntimeError(f"Workspace source references unknown node '{node_id}'.") - - def candidate_source_ref_from_state(path: Path) -> WorktreeSourceRef: return candidate_source_ref(load_source_ref_from_state(path)) diff --git a/src/crewplane/runtime/workspace/branch_export/fulfillment.py b/src/crewplane/runtime/workspace/branch_export/fulfillment.py index abb732d..e595333 100644 --- a/src/crewplane/runtime/workspace/branch_export/fulfillment.py +++ b/src/crewplane/runtime/workspace/branch_export/fulfillment.py @@ -91,7 +91,7 @@ def record_branch_export_fulfillment( except ValueError: record_relative_path = record_path.as_posix() state_payload = _workspace_state_payload(checkpoint.state_path) - state_payload["branch_export"] = { + branch_export: JsonObject = { "status": record_payload["status"], "operation": operation, "branch_name": record_payload["branch_name"], @@ -102,9 +102,8 @@ def record_branch_export_fulfillment( "completed_at": record_payload["created_at"], } if "failure_message" in record_payload: - state_payload["branch_export"]["failure_message"] = record_payload[ - "failure_message" - ] + branch_export["failure_message"] = record_payload["failure_message"] + state_payload["branch_export"] = branch_export atomic_write_json(checkpoint.state_path, state_payload) _refresh_node_manifest_workspace_descriptor(plan, node, stages_dir, results_dir) diff --git a/src/crewplane/runtime/workspace/branch_export/records.py b/src/crewplane/runtime/workspace/branch_export/records.py index 2619e24..bbd2554 100644 --- a/src/crewplane/runtime/workspace/branch_export/records.py +++ b/src/crewplane/runtime/workspace/branch_export/records.py @@ -9,6 +9,7 @@ PreflightExecutionPlan, WorkspaceSelectionRecord, ) +from crewplane.core.value_checks import is_nonnegative_int from crewplane.core.workspace.policy import generated_branch_name from crewplane.runtime.workspace.branch_export.fulfillment import ( BranchExportCheckpoint, @@ -170,7 +171,7 @@ def checkpoint_from_record( or result_ref is None or bundle_path is None or bundle_sha256 is None - or not _valid_size_bytes(bundle_size_bytes) + or not is_nonnegative_int(bundle_size_bytes) ): raise RuntimeError("Invalid branch export checkpoint record.") return BranchExportCheckpoint( @@ -227,7 +228,3 @@ def _checkpoint_fields_present(payload: JsonObject) -> bool: def _required_string(value: object) -> str | None: return value if isinstance(value, str) and value else None - - -def _valid_size_bytes(value: object) -> bool: - return isinstance(value, int) and not isinstance(value, bool) and value >= 0 diff --git a/src/crewplane/runtime/workspace/plan_nodes.py b/src/crewplane/runtime/workspace/plan_nodes.py new file mode 100644 index 0000000..62e05cf --- /dev/null +++ b/src/crewplane/runtime/workspace/plan_nodes.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from crewplane.core.preflight.models import ( + PreflightExecutionNode, + PreflightExecutionPlan, +) + + +def workspace_plan_node( + plan: PreflightExecutionPlan, + node_id: str, +) -> PreflightExecutionNode: + """Return a compiled workspace source node or raise the canonical error.""" + + for node in plan.nodes: + if node.id == node_id: + return node + raise RuntimeError(f"Workspace source references unknown node '{node_id}'.") diff --git a/src/crewplane/runtime/workspace/prepared_workspace.py b/src/crewplane/runtime/workspace/prepared_workspace.py index 2902927..01ab313 100644 --- a/src/crewplane/runtime/workspace/prepared_workspace.py +++ b/src/crewplane/runtime/workspace/prepared_workspace.py @@ -297,7 +297,7 @@ def _record_lineage_success( self.worktree_capture.source, self.workspace_path, ) - elif cache_entry is not None: + elif cache_entry is not None and self.reuse_cache is not None: self.reuse_cache.store(cache_entry) update_workspace_state( self.state_path, @@ -405,8 +405,6 @@ def _terminal_failure_retention( ) ) return "retained", f"{reason}_cleanup_failed" - if self.workspace_kind == "snapshot": - return "deleted", None return "retained", reason def _remove_failed_worktree(self) -> None: diff --git a/src/crewplane/runtime/workspace/service/common.py b/src/crewplane/runtime/workspace/service/common.py index d18d98b..16768aa 100644 --- a/src/crewplane/runtime/workspace/service/common.py +++ b/src/crewplane/runtime/workspace/service/common.py @@ -3,6 +3,7 @@ import json from pathlib import Path +from crewplane.architecture.contracts import JsonObject from crewplane.core.preflight.models import ( PreflightExecutionPlan, WorkspaceSourceSnapshot, @@ -107,7 +108,7 @@ def record_failed_preparation_state( def worktree_preparation_failure_state( failure: Exception, -) -> tuple[list[dict[str, str]], str, dict[str, object] | None]: +) -> tuple[list[dict[str, str]], str, JsonObject | None]: if isinstance(failure, WorkspaceSetupError): return ( [{"level": "error", "message": str(failure)}], diff --git a/src/crewplane/runtime/workspace/setup.py b/src/crewplane/runtime/workspace/setup.py index 691781b..354b57a 100644 --- a/src/crewplane/runtime/workspace/setup.py +++ b/src/crewplane/runtime/workspace/setup.py @@ -11,7 +11,11 @@ from threading import Lock from typing import TextIO -from crewplane.architecture.contracts import ChildProcessEnvironment, JsonObject +from crewplane.architecture.contracts import ( + ChildProcessEnvironment, + JsonObject, + JsonValue, +) from crewplane.artifacts.atomic import atomic_write_json from crewplane.core.platform import supports_posix_process_groups from crewplane.core.preflight.models import ( @@ -100,7 +104,7 @@ def run_workspace_setup( started_at = datetime.now(UTC).isoformat() started = time.monotonic() deadline = started + timeout_seconds - records: list[JsonObject] = [] + records: list[JsonValue] = [] status = "succeeded" timed_out = False failure_message: str | None = None diff --git a/src/crewplane/runtime/workspace/state.py b/src/crewplane/runtime/workspace/state.py index 58f390a..da6cfc8 100644 --- a/src/crewplane/runtime/workspace/state.py +++ b/src/crewplane/runtime/workspace/state.py @@ -298,7 +298,7 @@ def update_workspace_state( def update_workspace_setup( state_path: Path, - setup: dict[str, object], + setup: Mapping[str, object], base_payload: Mapping[str, object] | None = None, ) -> None: payload = ( diff --git a/src/crewplane/runtime/workspace/worktree/descriptors.py b/src/crewplane/runtime/workspace/worktree/descriptors.py index cceea2a..ee30b3b 100644 --- a/src/crewplane/runtime/workspace/worktree/descriptors.py +++ b/src/crewplane/runtime/workspace/worktree/descriptors.py @@ -2,11 +2,12 @@ import json from pathlib import Path +from typing import TypeIs from crewplane.core.preflight.models import PreflightExecutionPlan from crewplane.core.workflow.keywords import ProviderRole -from .types import WorktreeCaptureResult, WorktreeSourceRef +from .types import WorkspaceSourceKind, WorktreeCaptureResult, WorktreeSourceRef def load_source_ref_from_state(path: Path) -> WorktreeSourceRef: @@ -135,7 +136,7 @@ def _source_ref_from_payload( source_kind = _string(payload.get("kind")) source_commit = _string(payload.get("commit")) source_tree = _string(payload.get("tree")) - if source_kind not in {"project", "node", "candidate"}: + if not _is_workspace_source_kind(source_kind): return None if source_commit is None or source_tree is None: return None @@ -153,6 +154,10 @@ def _source_ref_from_payload( ) +def _is_workspace_source_kind(value: str | None) -> TypeIs[WorkspaceSourceKind]: + return value in {"project", "node", "candidate"} + + def _nested_upstream_sources( state_path: Path, payload: dict[str, object], diff --git a/src/crewplane/runtime/workspace/worktree/source_refs.py b/src/crewplane/runtime/workspace/worktree/source_refs.py index b5e4d58..dbb7076 100644 --- a/src/crewplane/runtime/workspace/worktree/source_refs.py +++ b/src/crewplane/runtime/workspace/worktree/source_refs.py @@ -11,6 +11,7 @@ ) from crewplane.core.workflow.keywords import ProviderRole +from ..plan_nodes import workspace_plan_node from ..state_selection import ( required_lineage_state_path, same_node_executor_state_path, @@ -47,7 +48,7 @@ def invocation_source_ref( return load_source_ref_from_state( required_lineage_state( output, - _plan_node(plan, policy.source_node_id), + workspace_plan_node(plan, policy.source_node_id), ) ) return WorktreeSourceRef( @@ -84,13 +85,3 @@ def same_node_executor_state( def _candidate_ref_from_state(state_path: Path) -> WorktreeSourceRef: return candidate_source_ref(load_source_ref_from_state(state_path)) - - -def _plan_node( - plan: PreflightExecutionPlan, - node_id: str, -) -> PreflightExecutionNode: - for node in plan.nodes: - if node.id == node_id: - return node - raise RuntimeError(f"Workspace source references unknown node '{node_id}'.") diff --git a/tests/integration/architecture/test_preflight_artifact_port.py b/tests/integration/architecture/test_preflight_artifact_port.py index 19854db..6e27b5d 100644 --- a/tests/integration/architecture/test_preflight_artifact_port.py +++ b/tests/integration/architecture/test_preflight_artifact_port.py @@ -3,6 +3,8 @@ import json from datetime import datetime from pathlib import Path +from types import SimpleNamespace +from typing import get_protocol_members import pytest @@ -142,6 +144,20 @@ def test_filesystem_output_manager_implements_current_artifact_store_port( assert require_artifact_store(output) is output +def test_artifact_store_contract_does_not_require_filesystem_base_dir( + tmp_path: Path, +) -> None: + output = OutputManager("Workflow", base_dir=tmp_path) + store = SimpleNamespace( + **{ + member: getattr(output, member) + for member in get_protocol_members(ArtifactStorePort) - {"base_dir"} + } + ) + + assert require_artifact_store(store) is store + + def test_artifact_store_loader_rejects_removed_store_contract() -> None: with pytest.raises(AdapterContractError, match="ArtifactStorePort"): require_artifact_store(object()) diff --git a/tests/integration/runtime/agent/test_invocation_command.py b/tests/integration/runtime/agent/test_invocation_command.py index b0a569a..eb27591 100644 --- a/tests/integration/runtime/agent/test_invocation_command.py +++ b/tests/integration/runtime/agent/test_invocation_command.py @@ -89,6 +89,36 @@ async def test_run_command_once_emits_started_and_exited_process_events( finally: result.cleanup_stream_files() + async def test_run_command_once_disables_unsupported_process_groups(self) -> None: + events: list[InvocationProcessEvent] = [] + context = InvocationContext( + node_id="node.a", + task_id="generic_executor_0", + provider="generic", + role=ProviderRole.EXECUTOR, + process_event_sink=events.append, + ) + + with patch( + "crewplane.runtime.agent.invocation.command.supports_posix_process_groups", + return_value=False, + ): + result = await run_command_once( + cmd=[sys.executable, "-c", "print('ok')"], + stdin_data=None, + log_file=None, + append_log=False, + log_header=None, + cwd=Path.cwd(), + invocation_context=context, + idle_timeout_seconds=None, + ) + try: + self.assertEqual(result.returncode, 0) + self.assertEqual([event.process_group_id for event in events], [None, None]) + finally: + result.cleanup_stream_files() + async def test_process_start_reporting_failure_reaps_spawned_process( self, ) -> None: @@ -184,6 +214,43 @@ def record_process_event(event: InvocationProcessEvent) -> None: ) ) + async def test_file_not_found_after_spawn_reaps_process(self) -> None: + created_processes: list[asyncio.subprocess.Process] = [] + original_create_subprocess_exec = asyncio.create_subprocess_exec + + async def tracking_create_subprocess_exec(*args, **kwargs): # type: ignore[no-untyped-def] + process = await original_create_subprocess_exec(*args, **kwargs) + created_processes.append(process) + return process + + with ( + patch( + "crewplane.runtime.agent.invocation.command.asyncio.create_subprocess_exec", + new=tracking_create_subprocess_exec, + ), + patch( + "crewplane.runtime.agent.invocation.command.open_log_handle", + side_effect=FileNotFoundError("log directory disappeared"), + ), + self.assertRaisesRegex( + RuntimeError, + "Execution error: log directory disappeared", + ), + ): + await run_command_once( + cmd=[sys.executable, "-c", "import time; time.sleep(10)"], + stdin_data=None, + log_file=Path("provider.log"), + append_log=False, + log_header=None, + cwd=Path.cwd(), + invocation_context=None, + idle_timeout_seconds=None, + ) + + self.assertEqual(len(created_processes), 1) + self.assertIsNotNone(created_processes[0].returncode) + async def test_process_exit_reporting_failure_cleans_stream_capture(self) -> None: cleanup_calls = 0 original_cleanup = ProcessOutputCapture.cleanup diff --git a/tests/typecheck/public_artifacts_consumer.py b/tests/typecheck/public_artifacts_consumer.py index 6a4010f..52406f5 100644 --- a/tests/typecheck/public_artifacts_consumer.py +++ b/tests/typecheck/public_artifacts_consumer.py @@ -8,7 +8,11 @@ NodeArtifactRequest, artifact_contract_for_node, ) -from crewplane.architecture.ports import ArtifactStorePort, RuntimeComponents +from crewplane.architecture.ports import ( + ArtifactStorePort, + RunSummaryArtifactReaderPort, + RuntimeComponents, +) from crewplane.architecture.ports.artifacts import StageFinalizeResult from crewplane.artifacts import FindingsExtractionError, OutputManager @@ -45,10 +49,19 @@ def construct_with_store( return components +def consume_summary_reader(store: RunSummaryArtifactReaderPort) -> None: + assert_type(store.stages_dir, Path) + assert_type(store.get_run_event_log_path(), Path) + assert_type(store.get_run_summary_path(), Path) + assert_type(store.get_node_artifact_request("build"), NodeArtifactRequest | None) + + assert_type(output.create_node_dir(request), Path) assert_type(output.get_node_output_path(request), Path) assert_type(output.get_node_findings_path(request), Path | None) assert_type(output.write_node_resume_source(request, {"source": "run-a"}), Path) +consume_store(output) +consume_summary_reader(output) try: raise FindingsExtractionError("invalid findings") diff --git a/tests/typecheck/type_guard_false_branches.py b/tests/typecheck/type_guard_false_branches.py new file mode 100644 index 0000000..17a35d2 --- /dev/null +++ b/tests/typecheck/type_guard_false_branches.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import assert_type + +from crewplane.artifacts.workspace.state.fields import is_hex_object +from crewplane.core.value_checks import is_nonnegative_int +from crewplane.runtime.execution.workspace_files.source_resolution import ( + WorkspaceCandidateSourceContext, + is_initial_pre_review_context, +) + + +def check_source_context( + value: WorkspaceCandidateSourceContext | None, +) -> None: + if is_initial_pre_review_context(value): + assert_type(value, WorkspaceCandidateSourceContext) + else: + assert_type(value, WorkspaceCandidateSourceContext | None) + + +def check_hex_object(value: str | int) -> None: + if is_hex_object(value): + assert_type(value, str) + else: + assert_type(value, str | int) + + +def check_nonnegative_int(value: int | str) -> None: + if is_nonnegative_int(value): + assert_type(value, int) + else: + assert_type(value, int | str) diff --git a/tests/unit/artifacts/test_lock_manifest.py b/tests/unit/artifacts/test_lock_manifest.py index 9086f23..ee65935 100644 --- a/tests/unit/artifacts/test_lock_manifest.py +++ b/tests/unit/artifacts/test_lock_manifest.py @@ -3,15 +3,17 @@ import pytest +from crewplane.architecture.safe_files import ( + path_has_symlink_component, + path_is_symlink, +) from crewplane.artifacts.locks.manifest import ( LockManifestError, LockRunMetadata, ensure_no_symlink_manifest_components, ensure_owner_path_contained, finalize_stale_running_run, - has_symlink_component, owner_manifest_path, - path_is_symlink, read_owner_manifest, safe_owner_manifest_path, ) @@ -147,7 +149,7 @@ def test_containment_helpers_reject_escape_and_symlink(tmp_path: Path) -> None: pytest.skip("symlink creation is unavailable") with pytest.raises(LockManifestError, match="contains a symlink"): ensure_no_symlink_manifest_components(root, linked / "run.json") - assert has_symlink_component(linked / "run.json") + assert path_has_symlink_component(linked / "run.json") assert path_is_symlink(linked) diff --git a/tests/unit/runtime/workspace/test_plan_nodes.py b/tests/unit/runtime/workspace/test_plan_nodes.py new file mode 100644 index 0000000..fbe2ca5 --- /dev/null +++ b/tests/unit/runtime/workspace/test_plan_nodes.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import pytest + +from crewplane.core.preflight import PreflightExecutionNode, PreflightExecutionPlan +from crewplane.runtime.workspace.plan_nodes import workspace_plan_node + + +def test_workspace_plan_node_returns_compiled_node() -> None: + node = PreflightExecutionNode.model_construct(id="source") + plan = PreflightExecutionPlan.model_construct(nodes=[node]) + + assert workspace_plan_node(plan, "source") is node + + +def test_workspace_plan_node_preserves_unknown_source_error() -> None: + plan = PreflightExecutionPlan.model_construct(nodes=[]) + + with pytest.raises( + RuntimeError, + match="Workspace source references unknown node 'missing'\\.", + ): + workspace_plan_node(plan, "missing") diff --git a/uv.lock b/uv.lock index 2900ae2..c2e9e07 100644 --- a/uv.lock +++ b/uv.lock @@ -268,6 +268,7 @@ dev = [ { name = "pytest-cov" }, { name = "ruff" }, { name = "twine" }, + { name = "types-pyyaml" }, ] stress = [ { name = "pytest-randomly" }, @@ -290,6 +291,7 @@ requires-dist = [ { name = "shellingham", specifier = ">=1.3.0" }, { name = "twine", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "typer", specifier = ">=0.12.0" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.12" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] provides-extras = ["dev", "stress"] @@ -957,6 +959,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260815" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/72/b56089aeee6c496d969bac42376bedb6e3eeab4682e1018fa3137122f94b/types_pyyaml-6.0.12.20260815.tar.gz", hash = "sha256:28764110c9cf35846e733da32d8d734df7473c5dde9ef67c3b7332ec0e819858", size = 18545, upload-time = "2026-08-15T02:41:51.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/52/eefeba09be4ef2a1eb989eb92934561e8e502a6ee3c32654996e4be7e399/types_pyyaml-6.0.12.20260815-py3-none-any.whl", hash = "sha256:6f332212b7e191f3afd5016a713c510b6340593b7ebec573c7d5d20aa5386d3b", size = 21148, upload-time = "2026-08-15T02:41:50.555Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"