diff --git a/.cargo/config.toml b/.cargo/config.toml index aac9e8b8..51a35f05 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,4 +1,4 @@ [alias] # Cross-platform retrieval dev setup (run from repository root). -retrieval-setup = "run -p codestory-cli -- retrieval bootstrap --project ." -retrieval-status = "run -p codestory-cli -- retrieval status --project ." +retrieval-setup = "run --locked -p codestory-cli -- retrieval bootstrap --project ." +retrieval-status = "run --locked -p codestory-cli -- retrieval status --project ." diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index 1ae755a5..4a0ba69a 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -3,4 +3,4 @@ version = 1 name = "codestory" [setup] -script = "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/codex-worktree-setup.ps1" +script = "node scripts/codex-worktree-setup.mjs" diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..f2f629d2 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +self-hosted-runner: + labels: + - codestory-metal + - codestory-release-evidence + +config-variables: null diff --git a/.github/scripts/check-linux-glibc-baseline.sh b/.github/scripts/check-linux-glibc-baseline.sh new file mode 100755 index 00000000..bebeaeef --- /dev/null +++ b/.github/scripts/check-linux-glibc-baseline.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +archive=$1 +expected_version=$2 +out_dir=$3 +expected_glibc=${4:-glibc 2.31} + +rm -rf "$out_dir" +mkdir -p "$out_dir/unpacked" "$out_dir/cache" + +actual_glibc=$(getconf GNU_LIBC_VERSION) +{ + printf 'distribution=' + . /etc/os-release + printf '%s %s\n' "$NAME" "$VERSION_ID" + printf 'expected_glibc=%s\n' "$expected_glibc" + printf 'actual_glibc=%s\n' "$actual_glibc" +} > "$out_dir/environment.txt" +test "$actual_glibc" = "$expected_glibc" + +tar -xzf "$archive" -C "$out_dir/unpacked" +cli=$(find "$out_dir/unpacked" -type f -name codestory-cli -print -quit) +test -n "$cli" +chmod +x "$cli" + +run_probe() { + local name=$1 + shift + set +e + "$@" > "$out_dir/$name.stdout.txt" 2> "$out_dir/$name.stderr.txt" + local status=$? + set -e + printf '%s\n' "$status" > "$out_dir/$name.exit-code.txt" + test "$status" -eq 0 +} + +run_probe version "$cli" --version +grep -F "$expected_version" "$out_dir/version.stdout.txt" + +run_probe help "$cli" --help +grep -Eiq 'usage:' "$out_dir/help.stdout.txt" + +initialize='{"jsonrpc":"2.0","id":"initialize","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"glibc-baseline-proof","version":"1.0.0"}}}' +set +e +printf '%s\n' "$initialize" | CODESTORY_CACHE_ROOT="$out_dir/cache" timeout 30s \ + "$cli" serve --stdio --refresh none --project /workspace \ + > "$out_dir/stdio-initialize.stdout.txt" 2> "$out_dir/stdio-initialize.stderr.txt" +stdio_status=${PIPESTATUS[1]} +set -e +printf '%s\n' "$stdio_status" > "$out_dir/stdio-initialize.exit-code.txt" +test "$stdio_status" -eq 0 +grep -Eq '"jsonrpc"[[:space:]]*:[[:space:]]*"2\.0"' "$out_dir/stdio-initialize.stdout.txt" +grep -Eq '"protocolVersion"[[:space:]]*:[[:space:]]*"2024-11-05"' "$out_dir/stdio-initialize.stdout.txt" +grep -Eq '"serverInfo"[[:space:]]*:' "$out_dir/stdio-initialize.stdout.txt" + +printf 'status=passed\n' >> "$out_dir/environment.txt" diff --git a/.github/scripts/check-packaged-agent-proof.py b/.github/scripts/check-packaged-agent-proof.py index 5722d1b4..4af53933 100644 --- a/.github/scripts/check-packaged-agent-proof.py +++ b/.github/scripts/check-packaged-agent-proof.py @@ -3,21 +3,26 @@ import argparse import contextlib +import ctypes +import datetime import hashlib +import http.server import json import os import queue import signal import shutil +import socket import stat +import struct import subprocess import sys import tarfile import tempfile import threading -import textwrap import time import zipfile +from collections.abc import Callable from pathlib import Path @@ -27,6 +32,11 @@ AGENT_GUIDE_URI = "codestory://agent-guide" SERVER_RESOURCE_URIS = (STATUS_URI, AGENT_GUIDE_URI) PLUGIN_SKILL_RELATIVE = Path("plugins/codestory/skills/codestory-grounding/SKILL.md") +PROOF_TEMP_OWNER_FILE = ".codestory-macos-metal-proof-owner.json" +PROOF_LOCAL_RUN_ID = "shared-agent" +PROOF_AGENT_RUN_IDS = (PROOF_LOCAL_RUN_ID,) +SIDECAR_STATE_FILE_V3 = "retrieval-sidecars-v3.json" +LEGACY_SIDECAR_STATE_FILE = "retrieval-sidecars.json" class GateFailure(Exception): @@ -104,6 +114,69 @@ def captured_text(value: str | bytes | None) -> str: return value or "" +def remove_tree_with_retry(path: Path, timeout_secs: float = 10.0, platform: str = os.name) -> None: + deadline = time.monotonic() + timeout_secs + while True: + try: + shutil.rmtree(path) + return + except FileNotFoundError: + return + except PermissionError as exc: + if platform != "nt" or getattr(exc, "winerror", None) not in {5, 32}: + raise + if time.monotonic() >= deadline: + raise + time.sleep(0.2) + + +def remove_owned_tree_with_retry( + cli: Path, + root: Path, + relative: Path, + timeout_secs: float = 10.0, + platform: str = os.name, +) -> None: + if ( + relative.is_absolute() + or not relative.parts + or any(part in {"", ".", ".."} for part in relative.parts) + ): + raise ValueError(f"owned cleanup requires a plain relative path: {relative}") + command = [ + str(cli), + "internal-owned-delete", + "--root", + str(root), + "--relative", + str(relative), + ] + deadline = time.monotonic() + timeout_secs + while True: + result = subprocess.run( + command, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode == 0: + return + if platform != "nt" or time.monotonic() >= deadline: + detail = result.stderr.strip() or result.stdout.strip() or "no command output" + raise RuntimeError(f"owned cleanup exited {result.returncode}: {detail}") + time.sleep(0.2) + + +@contextlib.contextmanager +def temporary_directory_with_retry(prefix: str, directory: Path): + path = Path(tempfile.mkdtemp(prefix=prefix, dir=directory)) + try: + yield str(path) + finally: + remove_tree_with_retry(path) + + def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: @@ -112,6 +185,107 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() +def bounded_file_copy(source: Path, destination: Path, max_bytes: int = 4 * 1024 * 1024) -> dict: + size = source.stat().st_size + destination.parent.mkdir(parents=True, exist_ok=True) + if size <= max_bytes: + shutil.copyfile(source, destination) + copied = size + truncated = False + else: + half = max_bytes // 2 + with source.open("rb") as handle: + head = handle.read(half) + handle.seek(max(0, size - half)) + tail = handle.read(half) + marker = f"\n--- CodeStory proof omitted {size - len(head) - len(tail)} log bytes ---\n".encode() + destination.write_bytes(head + marker + tail) + copied = destination.stat().st_size + truncated = True + return { + "source": str(source), + "source_size_bytes": size, + "source_sha256": sha256_file(source), + "preserved": str(destination), + "preserved_size_bytes": copied, + "truncated": truncated, + "max_source_bytes_preserved": max_bytes, + } + + +def preserve_native_embedding_evidence( + cache_root: Path, + out_dir: Path, + label: str, + *, + required: bool = False, + exact_launch: dict | None = None, +) -> Path | None: + state_file = cache_root / SIDECAR_STATE_FILE_V3 + metadata_artifact = out_dir / f"{label}-native-launch.json" + launch = exact_launch + if launch is None: + if not state_file.is_file() or state_file.is_symlink(): + if required: + raise RuntimeError(f"native proof state is missing or unsafe: {state_file}") + return None + state = read_json_file(state_file) + if not isinstance(state, dict) or state.get("owner") != "codestory": + if required: + raise RuntimeError(f"native proof state is not CodeStory-owned: {state_file}") + return None + launch = state.get("embedding_launch") + if not isinstance(launch, dict) or launch.get("launch_mode") != "native_spawned": + if required: + raise RuntimeError(f"native proof state has no native_spawned launch: {state_file}") + return None + log_value = launch.get("log_path") + payload = { + "cache_root": str(cache_root), + "state_file": str(state_file) if exact_launch is None else None, + "embedding_launch": launch, + } + if exact_launch is not None: + snapshot = registered_native_process_snapshot(launch) + payload["live_identity"] = snapshot + fingerprint = launch.get("launch_fingerprint_sha256") + if snapshot.get("status") != "matching" or not ( + isinstance(fingerprint, str) and len(fingerprint) == 64 + ): + payload["error"] = "exact broker launch identity is not live or fingerprinted" + write_json(metadata_artifact, payload) + if required: + raise RuntimeError(payload["error"]) + return metadata_artifact + if not isinstance(log_value, str) or not log_value: + payload["error"] = "native launch metadata has no log_path" + write_json(metadata_artifact, payload) + if required: + raise RuntimeError(payload["error"]) + return metadata_artifact + log_path = Path(log_value) + if log_path.is_symlink() or not log_path.is_file(): + payload["error"] = f"native launch log is missing or unsafe: {log_path}" + write_json(metadata_artifact, payload) + if required: + raise RuntimeError(payload["error"]) + return metadata_artifact + canonical_log = log_path.resolve(strict=True) + canonical_cache = cache_root.resolve(strict=True) + if not canonical_log.is_relative_to(canonical_cache): + payload["error"] = f"native launch log escaped proof cache: {canonical_log}" + write_json(metadata_artifact, payload) + if required: + raise RuntimeError(payload["error"]) + return metadata_artifact + payload["log"] = bounded_file_copy( + canonical_log, + out_dir / f"{label}-llama-server-native.log", + ) + write_json(metadata_artifact, payload) + return metadata_artifact + + def verify_archive_checksum(archive: Path, checksum_file: Path, artifact: Path) -> None: lines = checksum_file.read_text(encoding="utf-8").splitlines() expected = next( @@ -132,30 +306,1016 @@ def verify_archive_checksum(archive: Path, checksum_file: Path, artifact: Path) require(actual == expected, "checksum", artifact, f"checksum mismatch for {archive.name}") -def cleanup_proof_cache(cli: Path, project: Path, cache_root: Path) -> None: - env = {**os.environ, "CODESTORY_CACHE_ROOT": str(cache_root)} - for profile, extra in (("local", []), ("agent", ["--run-id", "shared-agent"])): +def proof_environment(base: dict[str, str]) -> dict[str, str]: + env = dict(base) + if sys.platform.startswith("linux") and hasattr(os, "getuid") and hasattr(os, "getgid"): + env["CODESTORY_QDRANT_USER"] = f"{os.getuid()}:{os.getgid()}" + env["CODESTORY_QDRANT_SNAPSHOTS_PATH"] = "/qdrant/storage/snapshots" + return env + + +def write_managed_convergence_fixture(project: Path) -> None: + (project / "src").mkdir(parents=True) + (project / "Cargo.toml").write_text( + '[package]\nname = "managed-convergence-fixture"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (project / "src" / "lib.rs").write_text( + "pub fn complete_publication() -> &'static str { \"initial\" }\n", + encoding="utf-8", + ) + + +def make_managed_convergence_fixture_stale(project: Path) -> None: + time.sleep(0.05) + (project / "src" / "lib.rs").write_text( + "pub fn complete_publication() -> &'static str { \"refreshed\" }\n", + encoding="utf-8", + ) + (project / "src" / "new_after_publication.rs").write_text( + "pub fn discovered_after_publication() {}\n", + encoding="utf-8", + ) + + +def make_repository_convergence_copy_stale(project: Path) -> Path: + probe = project / "crates" / "codestory-cli" / "src" / "managed_convergence_probe.rs" + if not probe.parent.is_dir(): + raise RuntimeError( + f"managed convergence proof cannot locate codestory-cli sources under {project}" + ) + probe.write_text( + "pub fn discovered_by_grounding_convergence() -> &'static str { \"fresh\" }\n", + encoding="utf-8", + ) + return probe + + +def proof_ownership_snapshot(cache_root: Path) -> dict[str, str]: + names = { + "local-refresh.lock", + "local-refresh-status.json", + "ready-repair-enqueue.lock", + "ready-repair-result.json", + "ready-repair-status.json", + } + return { + str(path.relative_to(cache_root)): sha256_file(path) + for path in cache_root.rglob("*") + if path.is_file() and path.name in names + } + + +def fnv1a_bytes(value: bytes) -> str: + digest = 0xCBF29CE484222325 + for byte in value: + digest ^= byte + digest = (digest * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF + return f"{digest:016x}" + + +def fnv1a_hex(value: str) -> str: + return fnv1a_bytes(os.fsencode(value)) + + +def workspace_id_v3_for_path(path: Path) -> str: + encoded = str(path).encode("utf-16le") if os.name == "nt" else os.fsencode(path) + return fnv1a_bytes(encoded) + + +def proof_agent_identity(cache_root: Path, project: Path, run_id: str) -> dict[str, str]: + canonical_cache = cache_root.resolve(strict=False) + canonical_project = project.resolve(strict=False) + workspace_id = workspace_id_v3_for_path(canonical_project) + namespace = f"codestory-agent-v3-{workspace_id}-{run_id}" + state_root = canonical_cache / "sidecars" / namespace + return { + "cache_root": str(canonical_cache), + "project": str(canonical_project), + "profile": "agent", + "run_id": run_id, + "namespace": namespace, + "compose_project": namespace, + "state_file": str(state_root / SIDECAR_STATE_FILE_V3), + "qdrant_data_dir": str(state_root / "qdrant"), + "lexical_data_dir": str(state_root / "lexical"), + "scip_artifacts_root": str(state_root / "scip"), + } + + +def proof_agent_identities(cache_roots: list[Path], project: Path) -> list[dict[str, str]]: + return [ + proof_agent_identity(cache, project, run_id) + for cache in cache_roots + for run_id in PROOF_AGENT_RUN_IDS + ] + + +def run_docker_json( + command: list[str], + run=subprocess.run, + env: dict[str, str] | None = None, +) -> object: + try: + result = run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=20, + check=False, + text=True, + env=env, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RuntimeError(f"could not inspect Docker proof resources: {exc}") from exc + if result.returncode != 0: + raise RuntimeError( + f"Docker proof resource inspection exited {result.returncode}: " + f"{captured_text(result.stderr).strip()}" + ) + body = captured_text(result.stdout).strip() + if not body: + return [] + try: + return json.loads(body) + except json.JSONDecodeError as aggregate_error: try: - subprocess.run( - [ - str(cli), - "retrieval", - "down", - "--project", - str(project), - "--profile", - profile, - *extra, - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=20, + return [json.loads(line) for line in body.splitlines() if line.strip()] + except json.JSONDecodeError as exc: + raise RuntimeError( + f"Docker proof resource inspection returned invalid JSON: {aggregate_error}; {exc}" + ) from exc + + +def docker_created_epoch_ms(value: object) -> int | None: + if not isinstance(value, str) or not value: + return None + candidate = value.strip() + if "." in candidate: + prefix, fraction_and_zone = candidate.rsplit(".", 1) + fraction_length = next( + ( + index + for index, character in enumerate(fraction_and_zone) + if not character.isdigit() + ), + len(fraction_and_zone), + ) + candidate = ( + f"{prefix}.{fraction_and_zone[:6]}" + f"{fraction_and_zone[fraction_length:]}" + ) + if candidate.endswith("Z"): + candidate = candidate[:-1] + "+00:00" + try: + parsed = datetime.datetime.fromisoformat(candidate) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.timezone.utc) + return int(parsed.timestamp() * 1000) + + +def docker_compose_resource_snapshot(state: dict, run=subprocess.run) -> dict: + compose_project = state.get("compose_project") + if not isinstance(compose_project, str) or not compose_project: + raise RuntimeError("proof sidecar state has no Compose project for Docker inspection") + inspect_env = { + **os.environ, + "CODESTORY_SIDECAR_NAMESPACE": str(state.get("namespace", "")), + "CODESTORY_QDRANT_DATA_DIR": str(state.get("qdrant_data_dir", "")), + } + + def matching_ids(kind: str) -> list[str]: + payload = run_docker_json( + [ + "docker", + kind, + "ls", + "--all" if kind == "container" else "--filter", + *( + ["--filter", f"label=com.docker.compose.project={compose_project}"] + if kind == "container" + else [f"label=com.docker.compose.project={compose_project}"] + ), + "--format", + "{{json .}}", + ], + run, + inspect_env, + ) + # Docker 29 emits a single JSON object (not an array) when `ls --format + # json` has exactly one match. Multiple matches remain newline-delimited + # objects and are normalized by `run_docker_json`. + if isinstance(payload, dict): + payload = [payload] + if not isinstance(payload, list): + raise RuntimeError(f"Docker {kind} listing was not a JSON array") + ids = [] + for item in payload: + if not isinstance(item, dict): + raise RuntimeError(f"Docker {kind} listing contained a non-object") + value = item.get("ID") + if not isinstance(value, str) or not value: + raise RuntimeError(f"Docker {kind} listing contained no ID") + ids.append(value) + return sorted(set(ids)) + + container_ids = matching_ids("container") + network_ids = matching_ids("network") + container_inspect = ( + run_docker_json(["docker", "container", "inspect", *container_ids], run, inspect_env) + if container_ids + else [] + ) + network_inspect = ( + run_docker_json(["docker", "network", "inspect", *network_ids], run, inspect_env) + if network_ids + else [] + ) + if not isinstance(container_inspect, list) or not isinstance(network_inspect, list): + raise RuntimeError("Docker proof resource inspect output was not an array") + + containers = [] + for item in container_inspect: + if not isinstance(item, dict): + raise RuntimeError("Docker container inspect contained a non-object") + config = item.get("Config") if isinstance(item.get("Config"), dict) else {} + labels = config.get("Labels") if isinstance(config.get("Labels"), dict) else {} + mounts = item.get("Mounts") if isinstance(item.get("Mounts"), list) else [] + containers.append( + { + "id": item.get("Id"), + "name": str(item.get("Name", "")).lstrip("/"), + "created": item.get("Created"), + "labels": {str(key): str(value) for key, value in sorted(labels.items())}, + "mounts": sorted( + [ + { + "type": mount.get("Type"), + "source": mount.get("Source"), + "destination": mount.get("Destination"), + } + for mount in mounts + if isinstance(mount, dict) + ], + key=lambda mount: ( + str(mount.get("destination")), + str(mount.get("source")), + ), + ), + } + ) + networks = [] + for item in network_inspect: + if not isinstance(item, dict): + raise RuntimeError("Docker network inspect contained a non-object") + labels = item.get("Labels") if isinstance(item.get("Labels"), dict) else {} + attached = item.get("Containers") if isinstance(item.get("Containers"), dict) else {} + networks.append( + { + "id": item.get("Id"), + "name": item.get("Name"), + "created": item.get("Created"), + "labels": {str(key): str(value) for key, value in sorted(labels.items())}, + "attached_container_ids": sorted(str(value) for value in attached), + } + ) + return { + "compose_project": compose_project, + "containers": sorted(containers, key=lambda item: (str(item.get("id")), str(item.get("name")))), + "networks": sorted(networks, key=lambda item: (str(item.get("id")), str(item.get("name")))), + } + + +def validate_proof_docker_resources( + state: dict, + observed: dict, + registered: dict | None = None, +) -> None: + if registered is not None: + if registered.get("compose_project") != observed.get("compose_project"): + raise RuntimeError("Docker resources changed their registered Compose project") + for kind in ("containers", "networks"): + registered_items = registered.get(kind) + observed_items = observed.get(kind) + if not isinstance(registered_items, list) or not isinstance(observed_items, list): + raise RuntimeError("registered Docker resource snapshot is malformed") + registered_by_id = { + item.get("id"): item + for item in registered_items + if isinstance(item, dict) and isinstance(item.get("id"), str) + } + observed_by_id = { + item.get("id"): item + for item in observed_items + if isinstance(item, dict) and isinstance(item.get("id"), str) + } + if len(registered_by_id) != len(registered_items) or len(observed_by_id) != len( + observed_items + ): + raise RuntimeError("Docker resource snapshot contains duplicate or invalid IDs") + if not set(observed_by_id).issubset(registered_by_id): + raise RuntimeError("Docker resources include IDs absent from proof registration") + for resource_id, item in observed_by_id.items(): + registered_item = registered_by_id[resource_id] + if kind == "containers" and item != registered_item: + raise RuntimeError( + f"Docker container {resource_id} changed after proof ownership registration" + ) + if kind == "networks" and any( + item.get(field) != registered_item.get(field) + for field in ("id", "name", "created", "labels") + ): + raise RuntimeError( + f"Docker network {resource_id} changed after proof ownership registration" + ) + compose_project = state["compose_project"] + if observed.get("compose_project") != compose_project: + raise RuntimeError("Docker resource snapshot changed the Compose project") + containers = observed.get("containers") + networks = observed.get("networks") + if not isinstance(containers, list) or not isinstance(networks, list): + raise RuntimeError("Docker resource snapshot is malformed") + if not containers and networks and registered is None: + raise RuntimeError("cannot prove an unregistered Compose network without its containers") + expected_container_ids = set() + services = set() + qdrant_created = None + for container in containers: + if not isinstance(container, dict): + raise RuntimeError("Docker resource snapshot contains a malformed container") + container_id = container.get("id") + labels = container.get("labels") + if not isinstance(container_id, str) or not container_id or not isinstance(labels, dict): + raise RuntimeError("Docker resource snapshot contains an unidentified container") + expected_labels = { + "com.docker.compose.project": compose_project, + "dev.codestory.owner": "codestory", + "dev.codestory.profile": "agent", + "dev.codestory.namespace": state["namespace"], + } + if any(labels.get(name) != value for name, value in expected_labels.items()): + raise RuntimeError(f"Docker container {container_id} has foreign ownership labels") + service = labels.get("com.docker.compose.service") + if service not in {"qdrant", "embed"} or service in services: + raise RuntimeError(f"Docker container {container_id} has an unexpected Compose service") + if container.get("name") != f"{state['namespace']}-{service}": + raise RuntimeError(f"Docker container {container_id} has a foreign resource name") + services.add(service) + expected_container_ids.add(container_id) + created = docker_created_epoch_ms(container.get("created")) + if created is None: + raise RuntimeError(f"Docker container {container_id} has no creation identity") + if service == "qdrant": + qdrant_created = created + expected_qdrant = Path(state["qdrant_data_dir"]).resolve(strict=False) + matching_mount = any( + isinstance(mount, dict) + and mount.get("type") == "bind" + and mount.get("destination") == "/qdrant/storage" + and isinstance(mount.get("source"), str) + and Path(mount["source"]).resolve(strict=False) == expected_qdrant + for mount in container.get("mounts", []) + ) + if not matching_mount: + raise RuntimeError( + f"Docker qdrant container {container_id} is mounted from a foreign cache" + ) + if containers and "qdrant" not in services: + raise RuntimeError("Docker proof resources have no cache-bound qdrant container") + if qdrant_created is not None: + started = state.get("started_at_epoch_ms") + if isinstance(started, int) and not (qdrant_created - 5_000 <= started <= qdrant_created + 1_800_000): + raise RuntimeError("Docker qdrant creation does not match the sidecar state lifetime") + for container in containers: + created = docker_created_epoch_ms(container.get("created")) + if created is None or abs(created - qdrant_created) > 300_000: + raise RuntimeError("Docker containers do not share the registered creation lifetime") + if len(networks) > 1: + raise RuntimeError("Docker proof resources contain multiple Compose networks") + for network in networks: + if not isinstance(network, dict): + raise RuntimeError("Docker resource snapshot contains a malformed network") + network_id = network.get("id") + labels = network.get("labels") + if not isinstance(network_id, str) or not network_id or not isinstance(labels, dict): + raise RuntimeError("Docker resource snapshot contains an unidentified network") + if ( + labels.get("com.docker.compose.project") != compose_project + or labels.get("com.docker.compose.network") != "default" + or network.get("name") != f"{compose_project}_default" + ): + raise RuntimeError(f"Docker network {network_id} has foreign ownership labels") + attached = network.get("attached_container_ids") + if not isinstance(attached, list) or not set(attached).issubset(expected_container_ids): + raise RuntimeError(f"Docker network {network_id} has foreign attached containers") + network_created = docker_created_epoch_ms(network.get("created")) + if network_created is None: + raise RuntimeError(f"Docker network {network_id} has no creation identity") + if qdrant_created is not None and abs(network_created - qdrant_created) > 300_000: + raise RuntimeError(f"Docker network {network_id} has a foreign creation lifetime") + + +def proof_temp_root_from_environment() -> Path | None: + value = os.environ.get("CODESTORY_PROOF_TEMP_ROOT", "").strip() + if not value: + return None + root = Path(value) + if root.is_symlink() or not root.is_dir(): + raise RuntimeError(f"proof temp root is missing or unsafe: {root}") + return root.resolve(strict=True) + + +def register_proof_temp_ownership(project: Path, cache_roots: list[Path], archive: Path) -> None: + root = proof_temp_root_from_environment() + if root is None: + return + canonical_project = project.resolve(strict=True) + canonical_caches = [] + for cache in cache_roots: + canonical = cache.resolve(strict=True) + if not canonical.is_relative_to(root): + raise RuntimeError(f"proof cache is outside registered proof temp root: {canonical}") + canonical_caches.append(str(canonical)) + canonical_archive = archive.resolve(strict=True) + owned_archive = root / canonical_archive.name + if owned_archive.is_symlink() or not owned_archive.is_file(): + raise RuntimeError(f"proof-owned archive copy is missing or unsafe: {owned_archive}") + if sha256_file(owned_archive) != sha256_file(canonical_archive): + raise RuntimeError("proof-owned archive copy does not match the verified input archive") + marker = root / PROOF_TEMP_OWNER_FILE + payload = { + "owner": "codestory-macos-metal-proof", + "repository": os.environ.get("GITHUB_REPOSITORY"), + "project": str(canonical_project), + "cache_roots": canonical_caches, + "sidecars": proof_agent_identities([Path(value) for value in canonical_caches], canonical_project), + "launches": [], + "ports": [], + "archive_name": owned_archive.name, + "archive_sha256": sha256_file(owned_archive), + "harness_pid": os.getpid(), + "created_at_epoch_ms": int(time.time() * 1000), + } + write_json(marker, payload) + + +def load_proof_temp_ownership() -> tuple[Path, dict] | None: + root = proof_temp_root_from_environment() + if root is None: + return None + marker = root / PROOF_TEMP_OWNER_FILE + payload = read_json_file(marker) + if not isinstance(payload, dict) or payload.get("owner") != "codestory-macos-metal-proof": + raise RuntimeError(f"proof temp owner marker is invalid: {marker}") + return marker, payload + + +def record_proof_runtime_identity( + payload: dict, + launch: dict | None, + ports: list[object] | tuple[object, ...] | None = None, +) -> None: + if isinstance(launch, dict) and launch.get("launch_mode") == "native_spawned": + fingerprint = (launch.get("pid"), launch.get("launch_fingerprint_sha256")) + existing = { + (item.get("pid"), item.get("launch_fingerprint_sha256")) + for item in payload.get("launches", []) + if isinstance(item, dict) + } + if fingerprint not in existing: + payload.setdefault("launches", []).append(launch) + known_ports = {value for value in payload.get("ports", []) if isinstance(value, int)} + for port in ports or (): + if isinstance(port, int) and 0 < port <= 65535 and port not in known_ports: + payload.setdefault("ports", []).append(port) + known_ports.add(port) + + +def register_current_proof_runtime(cache_root: Path) -> None: + ownership = load_proof_temp_ownership() + if ownership is None: + return + marker, payload = ownership + registered = payload.get("sidecars", []) + if not isinstance(registered, list): + raise RuntimeError(f"proof temp owner marker has invalid sidecars: {marker}") + for identity in registered: + if not isinstance(identity, dict): + raise RuntimeError(f"proof temp owner marker has invalid sidecar identity: {marker}") + if Path(str(identity.get("cache_root", ""))).resolve(strict=False) != cache_root.resolve(strict=True): + continue + validated = validated_proof_compose_state( + cache_root, + Path(identity.get("project", payload.get("project", "."))), + read_json_file, + run_id=str(identity.get("run_id", "")), + registered_identity=identity, + ) + if validated is None: + continue + _, state = validated + if state.get("compose_file") is not None: + snapshot = docker_compose_resource_snapshot(state) + registered_resources = identity.get("docker_resources") + validate_proof_docker_resources( + state, + snapshot, + registered_resources if isinstance(registered_resources, dict) else None, + ) + identity["docker_resources"] = snapshot + record_proof_runtime_identity( + payload, + state.get("embedding_launch"), + ( + state.get("qdrant_http_port"), + state.get("qdrant_grpc_port"), + state.get("embed_http_port"), + ), + ) + write_json(marker, payload) + + +def register_proof_launch(launch: dict | None, ports: list[object] | None = None) -> None: + ownership = load_proof_temp_ownership() + if ownership is None: + return + marker, payload = ownership + record_proof_runtime_identity(payload, launch, ports) + write_json(marker, payload) + + +def validated_proof_compose_state( + cache_root: Path, + project: Path, + read_state, + *, + run_id: str = PROOF_LOCAL_RUN_ID, + registered_identity: dict | None = None, + allow_missing_compose_file: bool = False, +) -> tuple[Path, dict] | None: + if cache_root.is_symlink() or project.is_symlink(): + raise RuntimeError("proof cache and project roots must not be symlinks") + cache_root = cache_root.resolve(strict=True) + project = project.resolve(strict=registered_identity is None) + for local_state in ( + cache_root / SIDECAR_STATE_FILE_V3, + cache_root / LEGACY_SIDECAR_STATE_FILE, + ): + if local_state.exists() or local_state.is_symlink(): + raise RuntimeError( + f"proof cleanup refuses the global local-sidecar namespace: {local_state}" + ) + expected = registered_identity or proof_agent_identity(cache_root, project, run_id) + required_identity = proof_agent_identity(cache_root, Path(expected.get("project", project)), run_id) + for name in ( + "cache_root", + "project", + "profile", + "run_id", + "namespace", + "compose_project", + "state_file", + "qdrant_data_dir", + "lexical_data_dir", + "scip_artifacts_root", + ): + if expected.get(name) != required_identity[name]: + raise RuntimeError(f"registered proof sidecar identity changed {name!r}") + if expected["namespace"] == "codestory-v3" or not expected["namespace"].startswith( + "codestory-agent-v3-" + ): + raise RuntimeError("proof sidecar namespace is not isolated from the global local namespace") + state_file = Path(expected["state_file"]) + canonical_state_file = state_file.resolve(strict=False) + if ( + state_file.is_symlink() + or canonical_state_file != state_file + or not canonical_state_file.is_relative_to(cache_root) + ): + raise RuntimeError(f"proof sidecar state escaped its cache root through a symlink: {state_file}") + if not state_file.exists(): + return None + if not state_file.is_file(): + raise RuntimeError(f"proof sidecar state is not a regular file: {state_file}") + state = read_state(state_file) + if not isinstance(state, dict): + raise TypeError(f"proof sidecar state is not an object: {state_file}") + expected_identity = { + "owner": "codestory", + "profile": "agent", + "run_id": run_id, + "namespace": expected["namespace"], + "compose_project": expected["compose_project"], + } + for name, expected_value in expected_identity.items(): + if state.get(name) != expected_value: + raise RuntimeError( + f"proof sidecar state {name} does not match {expected_value!r}: {state_file}" + ) + state = dict(state) + for name, expected_path in ( + ("qdrant_data_dir", Path(expected["qdrant_data_dir"])), + ("scip_artifacts_root", Path(expected["scip_artifacts_root"])), + ): + value = state.get(name) + if not isinstance(value, str) or not value: + raise TypeError(f"proof sidecar state {name} is not a path string: {state_file}") + observed = Path(value) + canonical_expected = expected_path.resolve(strict=False) + if ( + canonical_expected != expected_path + or not canonical_expected.is_relative_to(cache_root) + or observed.is_symlink() + or observed.resolve(strict=False) != canonical_expected + ): + raise RuntimeError(f"proof sidecar state {name} escaped its cache root: {state_file}") + lexical_expected = Path(expected["lexical_data_dir"]) + canonical_lexical_expected = lexical_expected.resolve(strict=False) + if ( + canonical_lexical_expected != lexical_expected + or not canonical_lexical_expected.is_relative_to(cache_root) + ): + raise RuntimeError(f"proof sidecar lexical_data_dir escaped its cache root: {state_file}") + lexical_value = state.get("lexical_data_dir") + if lexical_value is None: + lexical_value = state.get("zoekt_data_dir") + if not isinstance(lexical_value, str) or not lexical_value: + raise TypeError(f"proof sidecar state canonical or legacy lexical data directory is not a path string: {state_file}") + observed = Path(lexical_value) + if observed.is_symlink() or observed.resolve(strict=False) != canonical_lexical_expected: + raise RuntimeError(f"proof sidecar state lexical_data_dir escaped its cache root: {state_file}") + state["lexical_data_dir"] = str(lexical_expected) + state["proof_identity"] = expected + compose_value = state.get("compose_file") + if compose_value is None: + state["compose_file"] = None + return state_file, state + if not isinstance(compose_value, str) or not compose_value: + raise TypeError(f"proof sidecar compose_file is not a path string: {state_file}") + compose_file = Path(compose_value) + if compose_file.is_symlink(): + raise RuntimeError(f"proof sidecar compose file is not a regular canonical file: {compose_file}") + allowed_compose_paths = { + candidate.resolve(strict=False) + for candidate in ( + Path(expected["project"]) / "docker" / "retrieval-compose.yml", + cache_root / "retrieval-compose.yml", + ) + } + if not compose_file.is_file(): + if ( + allow_missing_compose_file + and compose_file.resolve(strict=False) in allowed_compose_paths + ): + state["compose_file_missing"] = True + return state_file, state + raise RuntimeError(f"proof sidecar compose file is not a regular canonical file: {compose_file}") + allowed_compose_files = set() + for candidate in ( + Path(expected["project"]) / "docker" / "retrieval-compose.yml", + cache_root / "retrieval-compose.yml", + ): + if not candidate.is_file() or candidate.is_symlink(): + continue + canonical_candidate = candidate.resolve(strict=True) + if candidate == canonical_candidate: + allowed_compose_files.add(canonical_candidate) + canonical_compose_file = compose_file.resolve(strict=True) + if canonical_compose_file not in allowed_compose_files: + raise RuntimeError(f"proof sidecar compose file is outside the allowed roots: {compose_file}") + return state_file, state + + +def cleanup_proof_compose( + cache_root: Path, + project: Path, + env: dict[str, str], + results: list[dict], + run, + read_state, + *, + run_id: str = PROOF_LOCAL_RUN_ID, + registered_identity: dict | None = None, +) -> None: + try: + validated = validated_proof_compose_state( + cache_root, + project, + read_state, + run_id=run_id, + registered_identity=registered_identity, + allow_missing_compose_file=True, + ) + except Exception as exc: + results.append( + { + "kind": "compose_state_validation", + "state_file": str( + registered_identity.get("state_file") + if isinstance(registered_identity, dict) + else proof_agent_identity(cache_root, project, run_id)["state_file"] + ), + "error": f"{type(exc).__name__}: {exc}", + } + ) + raise RuntimeError(f"proof-owned Compose state validation failed: {exc}") from exc + if validated is None: + return + state_file, state = validated + if state.get("compose_file") is None: + results.append( + { + "kind": "compose_down_skipped", + "state_file": str(state_file), + "proof_identity": state["proof_identity"], + "reason": "sidecar_state_has_no_compose_file", + } + ) + return + try: + observed = docker_compose_resource_snapshot(state, run) + registered_resources = ( + registered_identity.get("docker_resources") + if isinstance(registered_identity, dict) + else None + ) + validate_proof_docker_resources( + state, + observed, + registered_resources if isinstance(registered_resources, dict) else None, + ) + except Exception as exc: + results.append( + { + "kind": "docker_resource_validation", + "state_file": str(state_file), + "proof_identity": state["proof_identity"], + "error": f"{type(exc).__name__}: {exc}", + } + ) + raise RuntimeError(f"proof-owned Docker resource validation failed: {exc}") from exc + containers = [item["id"] for item in observed["containers"]] + networks = [item["id"] for item in observed["networks"]] + resource_env = { + **env, + "CODESTORY_SIDECAR_NAMESPACE": state["namespace"], + "CODESTORY_QDRANT_DATA_DIR": state["qdrant_data_dir"], + } + for kind, ids in (("container", containers), ("network", networks)): + if not ids: + continue + command = [ + "docker", + kind, + "rm", + *(("-f",) if kind == "container" else ()), + *ids, + ] + try: + result = run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30, check=False, - env=env, + env=resource_env, + text=True, ) - except (OSError, subprocess.TimeoutExpired): - pass - shutil.rmtree(cache_root, ignore_errors=True) + except (OSError, subprocess.TimeoutExpired) as exc: + results.append( + {"kind": f"docker_{kind}_remove", "state_file": str(state_file), "error": str(exc)} + ) + raise RuntimeError(f"could not remove exact proof-owned Docker {kind}s: {exc}") from exc + results.append( + { + "kind": f"docker_{kind}_remove", + "state_file": str(state_file), + "proof_identity": state["proof_identity"], + "resource_ids": ids, + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + ) + if result.returncode != 0: + raise RuntimeError(f"proof-owned Docker {kind} cleanup exited {result.returncode}") + if not containers and not networks: + results.append( + { + "kind": "docker_resources_absent", + "state_file": str(state_file), + "proof_identity": state["proof_identity"], + } + ) + + +def cleanup_proof_cache( + cli: Path | None, + project: Path, + cache_root: Path, + artifact: Path, + run=subprocess.run, + read_state=read_json_file, + *, + registered_sidecars: list[dict] | None = None, + direct_only: bool = False, + owned_delete: Callable[[Path, Path], None] | None = None, +) -> None: + if owned_delete is None: + if cli is None: + raise RuntimeError("proof cache cleanup requires the packaged CLI owned-deletion boundary") + owned_delete = lambda root, relative: remove_owned_tree_with_retry(cli, root, relative) + env = {**os.environ, "CODESTORY_CACHE_ROOT": str(cache_root)} + results = [] + errors = [] + canonical_cache = cache_root.resolve(strict=True) + identities = ( + registered_sidecars + if registered_sidecars is not None + else proof_agent_identities([canonical_cache], project) + ) + identities = [ + identity + for identity in identities + if isinstance(identity, dict) + and Path(str(identity.get("cache_root", ""))).resolve(strict=False) == canonical_cache + ] + expected_state_files = { + Path(identity["state_file"]).resolve(strict=False) + for identity in identities + if isinstance(identity.get("state_file"), str) + } + discovered_state_files = { + path.resolve(strict=False) + for path in (canonical_cache / "sidecars").glob(f"*/{SIDECAR_STATE_FILE_V3}") + } + unexpected = sorted(str(path) for path in discovered_state_files - expected_state_files) + if unexpected: + error = f"proof cache contains unregistered sidecar state: {unexpected}" + results.append({"kind": "compose_state_validation", "error": error}) + errors.append(error) + worker_cleanup, worker_errors = cleanup_proof_owned_repair_workers( + canonical_cache, + project, + identities, + ) + results.extend(worker_cleanup) + errors.extend(worker_errors) + validated_states: list[tuple[dict, dict]] = [] + for identity in identities: + try: + run_id = identity.get("run_id") + if run_id not in PROOF_AGENT_RUN_IDS: + raise RuntimeError(f"unapproved proof sidecar run id: {run_id!r}") + validated = validated_proof_compose_state( + canonical_cache, + project, + read_state, + run_id=run_id, + registered_identity=identity, + allow_missing_compose_file=True, + ) + if validated is None: + continue + _, state = validated + validated_states.append((identity, state)) + cleanup_proof_compose( + canonical_cache, + project, + env, + results, + run, + read_state, + run_id=run_id, + registered_identity=identity, + ) + except Exception as exc: + error = f"{type(exc).__name__}: {exc}" + if not results or results[-1].get("error") != error: + results.append( + { + "kind": "compose_state_validation", + "state_file": str(identity.get("state_file", "")), + "error": error, + } + ) + errors.append(error) + seen_launches = set() + for _identity, state in validated_states: + launch = state.get("embedding_launch") + if ( + not isinstance(launch, dict) + or state.get("embedding_launch_ownership", "owner") != "owner" + ): + continue + fingerprint = (launch.get("pid"), launch.get("launch_fingerprint_sha256")) + if fingerprint in seen_launches: + continue + seen_launches.add(fingerprint) + try: + results.append( + { + "kind": "native_process_cleanup", + "result": terminate_registered_native_process(launch), + } + ) + except Exception as exc: + error = f"{type(exc).__name__}: {exc}" + results.append( + { + "kind": "native_process_cleanup", + "pid": launch.get("pid"), + "error": error, + } + ) + errors.append(error) + results.append( + { + "kind": "retrieval_down_skipped", + "reason": "trusted_direct_cleanup" if direct_only else "exact_registered_resource_cleanup", + "validated_sidecars": [identity for identity, _ in validated_states], + } + ) + if errors: + write_json( + artifact, + { + "cache_root": str(cache_root), + "commands": results, + "removed": False, + "errors": errors, + }, + ) + raise RuntimeError( + "proof-owned sidecar cleanup had failures after attempting every registered resource: " + + "; ".join(errors) + ) + try: + owned_delete(canonical_cache.parent, Path(canonical_cache.name)) + except (OSError, RuntimeError, ValueError) as exc: + write_json( + artifact, + {"cache_root": str(cache_root), "commands": results, "removed": False, "error": str(exc)}, + ) + raise + removed = not cache_root.exists() + write_json(artifact, {"cache_root": str(cache_root), "commands": results, "removed": removed}) + if not removed: + raise RuntimeError(f"proof cache still exists after cleanup: {cache_root}") + + +def cleanup_proof_cache_on_exit( + cli: Path, + project: Path, + cache_root: Path, + artifact: Path, + run=subprocess.run, + read_state=read_json_file, + registered_sidecars: list[dict] | None = None, +): + def cleanup(exc_type, exc, traceback) -> bool: + evidence_error = None + try: + preserve_native_embedding_evidence(cache_root, artifact.parent, "native-final") + except Exception as preserve_exc: + evidence_error = preserve_exc + try: + cleanup_proof_cache( + cli, + project, + cache_root, + artifact, + run, + read_state, + registered_sidecars=registered_sidecars, + ) + except Exception as cleanup_exc: + if exc is None: + raise + if hasattr(exc, "add_note"): + exc.add_note(f"proof cleanup also failed: {type(cleanup_exc).__name__}: {cleanup_exc}") + if evidence_error is not None: + if exc is None: + raise evidence_error + if hasattr(exc, "add_note"): + exc.add_note( + f"native evidence preservation also failed: " + f"{type(evidence_error).__name__}: {evidence_error}" + ) + return False + + return cleanup + + +def proof_agent_environment(base: dict[str, str], run_id: str) -> dict[str, str]: + """Keep proof sidecars out of the process-global local Compose namespace.""" + return { + **base, + "CODESTORY_SIDECAR_PROFILE": "agent", + "CODESTORY_SIDECAR_RUN_ID": run_id, + } def require_plugin_manifest_version(plugin_root: Path, expected_version: str) -> None: @@ -230,6 +1390,70 @@ def require(value: bool, layer: str, artifact: Path, message: str) -> None: fail(layer, artifact, message) +def seed_stale_local_publication( + cli: Path, + project: Path, + out_dir: Path, + timeout_secs: int, + env: dict[str, str], + make_stale: Callable[[Path], object], +) -> dict[str, str]: + ready_artifact = out_dir / "managed-local-ready.json" + run_command( + cli, + "managed_local_ready", + [ + "ready", + "--goal", + "local", + "--repair", + "--project", + str(project), + "--format", + "json", + "--output-file", + str(ready_artifact), + ], + ready_artifact, + timeout_secs, + env=env, + ) + + ground_artifact = out_dir / "managed-local-ground.json" + ground = run_command( + cli, + "managed_local_ground", + [ + "ground", + "--project", + str(project), + "--refresh", + "none", + "--format", + "json", + "--output-file", + str(ground_artifact), + ], + ground_artifact, + timeout_secs, + env=env, + ) + require( + isinstance(ground, dict) and isinstance(ground.get("stats"), dict), + "managed_local_ground", + ground_artifact, + "managed local ground output is missing repository stats", + ) + stale_result = make_stale(project) + stale_artifact = out_dir / "managed-local-stale.json" + write_json(stale_artifact, {"project": str(project), "mutation": str(stale_result)}) + return { + "managed_local_ready": str(ready_artifact), + "managed_local_ground": str(ground_artifact), + "managed_local_stale": str(stale_artifact), + } + + def require_agent_ready(payload: object, layer: str, artifact: Path) -> None: verdicts = payload.get("verdicts", []) if isinstance(payload, dict) else [] agent = next((item for item in verdicts if item.get("goal") == "agent_packet_search"), None) @@ -245,6 +1469,456 @@ def require_agent_ready(payload: object, layer: str, artifact: Path) -> None: require(isinstance(agent.get("full_repair"), list), layer, artifact, "agent readiness missing full_repair") +def require_native_accelerator_ready( + payload: object, + layer: str, + artifact: Path, + expected_pid: int | None = None, +) -> dict: + require_agent_ready(payload, layer, artifact) + require(isinstance(payload, dict), layer, artifact, "ready output is not a JSON object") + broker = payload.get("readiness_broker") + require(isinstance(broker, dict), layer, artifact, "ready output missing readiness_broker") + proof = broker.get("gpu_proof") + require(isinstance(proof, dict), layer, artifact, "ready output missing gpu_proof") + require(proof.get("proof_status") == "verified", layer, artifact, "native GPU proof is not verified") + require( + proof.get("meaningful_accelerator_work_proven") is True, + layer, + artifact, + "native GPU proof did not prove meaningful accelerator work", + ) + require(proof.get("embed_smoke_ok") is True, layer, artifact, "native embed smoke did not pass") + require(proof.get("observation_source") == "native_log", layer, artifact, "GPU proof is not native-log-backed") + identity = proof.get("runtime_identity") + require(isinstance(identity, dict), layer, artifact, "GPU proof missing runtime identity") + launch = identity.get("embedding_launch") + require(isinstance(launch, dict), layer, artifact, "GPU proof missing native launch identity") + require(launch.get("launch_mode") == "native_spawned", layer, artifact, "embedding launch is not native_spawned") + pid = launch.get("pid") + require(isinstance(pid, int) and pid > 0, layer, artifact, "native launch pid is invalid") + require( + isinstance(launch.get("spawned_at_epoch_ms"), int) + and isinstance(launch.get("executable_path"), str) + and isinstance(launch.get("log_path"), str) + and isinstance(launch.get("launch_fingerprint_sha256"), str) + and len(launch["launch_fingerprint_sha256"]) == 64, + layer, + artifact, + "native launch identity is missing executable, start, log, or fingerprint evidence", + ) + if expected_pid is not None: + require(pid == expected_pid, layer, artifact, f"native launch pid changed: expected {expected_pid}, got {pid}") + return launch + + +def require_agent_not_ready(payload: object, layer: str, artifact: Path) -> None: + verdicts = payload.get("verdicts", []) if isinstance(payload, dict) else [] + agent = next((item for item in verdicts if item.get("goal") == "agent_packet_search"), None) + require(agent is not None, layer, artifact, "missing agent_packet_search readiness verdict") + require(agent.get("status") != "ready", layer, artifact, "dead native endpoint still reported agent ready") + broker = payload.get("readiness_broker") if isinstance(payload, dict) else None + proof = broker.get("gpu_proof") if isinstance(broker, dict) else None + require( + isinstance(proof, dict) and proof.get("proof_status") == "gpu_unverified", + layer, + artifact, + "dead native endpoint did not invalidate GPU proof", + ) + + +def require_intel_default_backend_failure(payload: object, artifact: Path) -> None: + require(isinstance(payload, dict), "intel_default_backend", artifact, "bootstrap output is not an object") + state = payload.get("sidecar_state") + status = payload.get("project_status") + require(isinstance(state, dict), "intel_default_backend", artifact, "bootstrap output is missing sidecar_state") + require(isinstance(status, dict), "intel_default_backend", artifact, "bootstrap output is missing project_status") + require(payload.get("compose_started") is False, "intel_default_backend", artifact, "Intel default proof unexpectedly started Compose") + require(payload.get("embed_reachable") is False, "intel_default_backend", artifact, "Intel default proof unexpectedly reached an embedding backend") + require(status.get("retrieval_mode") != "full", "intel_default_backend", artifact, "Intel default proof incorrectly reported full retrieval") + require(isinstance(status.get("repair"), dict), "intel_default_backend", artifact, "Intel default failure is missing actionable repair guidance") + provider = state.get("embedding_accelerator_request_provider") + require(provider != "metal", "intel_default_backend", artifact, "Intel default proof made a Metal claim") + require(state.get("embedding_cpu_allowed") is False, "intel_default_backend", artifact, "Intel default proof silently allowed CPU retrieval") + + +def require_intel_cpu_external_ready(payload: object, artifact: Path, endpoint: str) -> None: + require(isinstance(payload, dict), "intel_cpu_external", artifact, "bootstrap output is not an object") + state = payload.get("sidecar_state") + require(isinstance(state, dict), "intel_cpu_external", artifact, "bootstrap output is missing sidecar_state") + require(payload.get("compose_started") is False, "intel_cpu_external", artifact, "external endpoint proof unexpectedly started Compose") + require(payload.get("embed_reachable") is True, "intel_cpu_external", artifact, "explicit CPU/external embedding endpoint was not reachable") + require(state.get("embed_url") == endpoint, "intel_cpu_external", artifact, "sidecar state did not retain the explicit embedding endpoint") + require(state.get("embedding_device_policy") == "cpu_allowed", "intel_cpu_external", artifact, "CPU policy was not labelled cpu_allowed") + require(state.get("embedding_device_state") == "cpu", "intel_cpu_external", artifact, "CPU runtime was not labelled cpu") + require(state.get("embedding_device_observation_source") == "cpu_policy", "intel_cpu_external", artifact, "CPU runtime observation source is not cpu_policy") + require(state.get("embedding_cpu_allowed") is True, "intel_cpu_external", artifact, "CPU runtime did not report explicit CPU allowance") + require(state.get("embedding_accelerator_requested") is False, "intel_cpu_external", artifact, "CPU runtime still requested an accelerator") + require(state.get("embedding_accelerator_request_provider") is None, "intel_cpu_external", artifact, "CPU runtime retained an accelerator provider") + require("metal" not in json.dumps(payload).lower(), "intel_cpu_external", artifact, "Intel CPU/external proof made a Metal claim") + + +@contextlib.contextmanager +def embedding_probe_server(): + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + length = int(self.headers.get("content-length", "0")) + if length: + self.rfile.read(length) + body = json.dumps({"data": [{"index": 0, "embedding": [0.0] * 768}]}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, _format: str, *_args: object) -> None: + return + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + yield f"http://127.0.0.1:{server.server_port}/v1/embeddings" + finally: + server.shutdown() + server.server_close() + worker.join(timeout=5) + + +def wait_for_process_exit(pid: int, timeout_secs: float = 15.0) -> None: + deadline = time.monotonic() + timeout_secs + while time.monotonic() < deadline: + try: + waited_pid, _status = os.waitpid(pid, os.WNOHANG) + if waited_pid == pid: + return + except ChildProcessError: + pass + try: + os.kill(pid, 0) + except ProcessLookupError: + return + time.sleep(0.1) + raise TimeoutError(f"native embedding pid {pid} did not exit after SIGTERM") + + +def darwin_process_argv(pid: int) -> tuple[str, list[str]] | None: + if sys.platform != "darwin": + return None + libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True) + libc.sysctl.argtypes = [ + ctypes.POINTER(ctypes.c_int), + ctypes.c_uint, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_size_t), + ctypes.c_void_p, + ctypes.c_size_t, + ] + libc.sysctl.restype = ctypes.c_int + mib = (ctypes.c_int * 3)(1, 49, pid) # CTL_KERN, KERN_PROCARGS2, pid + size = ctypes.c_size_t(0) + if libc.sysctl(mib, 3, None, ctypes.byref(size), None, 0) != 0 or size.value < 8: + return None + buffer = ctypes.create_string_buffer(size.value) + if libc.sysctl(mib, 3, buffer, ctypes.byref(size), None, 0) != 0: + return None + data = buffer.raw[: size.value] + argc = struct.unpack_from("=i", data, 0)[0] + if argc <= 0 or argc > 4096: + return None + cursor = 4 + + def read_c_string(offset: int) -> tuple[str, int] | None: + end = data.find(b"\0", offset) + if end < 0: + return None + return data[offset:end].decode("utf-8", errors="surrogateescape"), end + 1 + + executable_entry = read_c_string(cursor) + if executable_entry is None: + return None + executable, cursor = executable_entry + while cursor < len(data) and data[cursor] == 0: + cursor += 1 + argv = [] + for _ in range(argc): + entry = read_c_string(cursor) + if entry is None: + return None + argument, cursor = entry + argv.append(argument) + return executable, argv + + +def registered_native_process_snapshot(launch: dict) -> dict: + pid = launch.get("pid") + if not isinstance(pid, int) or pid <= 0: + return {"pid": pid, "status": "invalid_pid"} + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "lstart="], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={**os.environ, "LC_ALL": "C"}, + check=False, + ) + if result.returncode != 0 or not result.stdout.strip(): + return {"pid": pid, "status": "already_exited", "stderr": result.stderr} + start_text = result.stdout.strip() + executable = launch.get("executable_path") + arguments = launch.get("launch_args") + exact_process = darwin_process_argv(pid) + recorded_start = launch.get("spawned_at_epoch_ms") + observed_start = None + try: + parsed_start = datetime.datetime.strptime(start_text, "%a %b %d %H:%M:%S %Y") + observed_start = int(time.mktime(parsed_start.timetuple()) * 1000) + except ValueError: + pass + start_matches = ( + isinstance(recorded_start, int) + and observed_start is not None + and abs(observed_start - recorded_start) <= 5_000 + ) + exact_executable = None + exact_argv = None + argv_matches = False + if exact_process is not None and isinstance(executable, str) and isinstance(arguments, list): + exact_executable, exact_argv = exact_process + argv_matches = ( + os.path.realpath(exact_executable) == os.path.realpath(executable) + and exact_argv[1:] == [str(item) for item in arguments] + ) + matches = argv_matches and start_matches + return { + "pid": pid, + "status": "matching" if matches else "identity_mismatch", + "observed_executable": exact_executable, + "observed_argv": exact_argv, + "observed_start": start_text, + "observed_start_epoch_ms": observed_start, + "recorded_spawned_at_epoch_ms": recorded_start, + "expected_executable": executable, + "expected_arguments": arguments, + } + + +def terminate_registered_native_process(launch: dict) -> dict: + snapshot = registered_native_process_snapshot(launch) + if snapshot["status"] in {"already_exited", "invalid_pid"}: + return snapshot + if snapshot["status"] != "matching": + raise RuntimeError( + f"refusing to terminate native pid {snapshot.get('pid')}: registered identity no longer matches" + ) + pid = snapshot["pid"] + os.kill(pid, signal.SIGTERM) + try: + wait_for_process_exit(pid) + snapshot["status"] = "terminated" + except TimeoutError: + os.kill(pid, signal.SIGKILL) + wait_for_process_exit(pid, timeout_secs=5) + snapshot["status"] = "killed" + return snapshot + + +def port_reachability(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.settimeout(0.25) + return probe.connect_ex(("127.0.0.1", port)) == 0 + + +def cleanup_registered_proof_temp_root(args: argparse.Namespace) -> None: + cleanup_run = getattr(args, "run", subprocess.run) + candidate_root = Path(args.cleanup_proof_temp_root) + if candidate_root.is_symlink() or not candidate_root.is_dir(): + raise RuntimeError(f"proof cleanup root is missing or unsafe: {candidate_root}") + root = candidate_root.resolve(strict=True) + runner_temp = os.environ.get("RUNNER_TEMP", "").strip() + if runner_temp: + canonical_runner_temp = Path(runner_temp).resolve(strict=True) + if root.parent != canonical_runner_temp or not root.name.startswith("codestory-metal-proof-owned-"): + raise RuntimeError(f"proof cleanup root is outside the exact runner temp boundary: {root}") + marker = root / PROOF_TEMP_OWNER_FILE + if marker.is_symlink() or not marker.is_file(): + raise RuntimeError(f"proof cleanup root has no safe ownership marker: {root}") + ownership = read_json_file(marker) + if not isinstance(ownership, dict) or ownership.get("owner") != "codestory-macos-metal-proof": + raise RuntimeError(f"proof cleanup ownership marker is invalid: {marker}") + repository = os.environ.get("GITHUB_REPOSITORY") + if repository and ownership.get("repository") != repository: + raise RuntimeError( + f"proof cleanup marker repository does not match {repository!r}: {marker}" + ) + archive_name = ownership.get("archive_name") + archive_digest = ownership.get("archive_sha256") + if ( + not isinstance(archive_name, str) + or Path(archive_name).name != archive_name + or not isinstance(archive_digest, str) + or len(archive_digest) != 64 + ): + raise RuntimeError(f"proof cleanup marker has no bound archive identity: {marker}") + owned_archive = root / archive_name + if owned_archive.is_symlink() or not owned_archive.is_file(): + raise RuntimeError(f"proof cleanup bound archive is missing or unsafe: {owned_archive}") + if sha256_file(owned_archive) != archive_digest: + raise RuntimeError(f"proof cleanup bound archive digest changed: {owned_archive}") + out_dir = Path(args.out_dir).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + if out_dir.is_relative_to(root): + raise RuntimeError("proof cleanup artifacts must be outside the removable proof root") + owned_delete = getattr(args, "owned_delete", None) + cleanup_cli = None + if owned_delete is None: + cleanup_cli_dir = out_dir / "owned-delete-cli" + if cleanup_cli_dir.exists(): + remove_tree_with_retry(cleanup_cli_dir) + cleanup_cli_dir.mkdir() + unpack_archive(owned_archive, cleanup_cli_dir) + cleanup_cli = find_cli(cleanup_cli_dir) + owned_delete = lambda boundary, relative: remove_owned_tree_with_retry( + cleanup_cli, boundary, relative + ) + project_value = ownership.get("project") + project = ( + Path(project_value).resolve(strict=False) + if isinstance(project_value, str) + else Path(args.project).resolve(strict=False) + ) + results = { + "root": str(root), + "cache_cleanup": [], + "native_processes": [], + "ports": [], + "errors": [], + } + registered_sidecars = ownership.get("sidecars", []) + if not registered_sidecars or not isinstance(registered_sidecars, list) or not all( + isinstance(item, dict) for item in registered_sidecars + ): + raise RuntimeError(f"proof cleanup marker has invalid registered sidecars: {marker}") + recorded_launches = [ + item for item in ownership.get("launches", []) if isinstance(item, dict) + ] + seen_pids = set() + + def terminate_launches(launches: list[dict]) -> None: + for launch in launches: + fingerprint = (launch.get("pid"), launch.get("launch_fingerprint_sha256")) + if fingerprint in seen_pids: + continue + seen_pids.add(fingerprint) + try: + results["native_processes"].append(terminate_registered_native_process(launch)) + except Exception as exc: + error = f"{type(exc).__name__}: {exc}" + results["native_processes"].append( + {"pid": launch.get("pid"), "status": "failed", "error": error} + ) + results["errors"].append( + {"kind": "native_process", "pid": launch.get("pid"), "error": error} + ) + + # Marker-bound process identities are independent of ephemeral project and + # Compose files, so always attempt them before inspecting stale sidecar state. + terminate_launches(recorded_launches) + state_launches = [] + for identity in registered_sidecars: + state_file = Path(str(identity.get("state_file", ""))) + if not state_file.is_file() or state_file.is_symlink(): + continue + try: + validated = validated_proof_compose_state( + Path(str(identity["cache_root"])), + Path(str(identity["project"])), + read_json_file, + run_id=str(identity["run_id"]), + registered_identity=identity, + allow_missing_compose_file=True, + ) + if validated is not None: + launch = validated[1].get("embedding_launch") + if isinstance(launch, dict): + state_launches.append(launch) + except Exception as exc: + error = f"{type(exc).__name__}: {exc}" + results["errors"].append( + { + "kind": "sidecar_state_validation", + "state_file": str(state_file), + "error": error, + } + ) + terminate_launches(state_launches) + for index, value in enumerate(ownership.get("cache_roots", [])): + if not isinstance(value, str): + error = "proof cleanup marker contains a non-string cache root" + results["cache_cleanup"].append( + {"cache_root": repr(value), "status": "failed", "error": error} + ) + results["errors"].append({"kind": "cache_cleanup", "error": error}) + continue + cache = Path(value) + canonical = cache.resolve(strict=False) + if not canonical.is_relative_to(root) or not cache.name.startswith("codestory-packaged-"): + error = f"refusing unregistered proof cache cleanup: {cache}" + results["cache_cleanup"].append( + {"cache_root": str(cache), "status": "failed", "error": error} + ) + results["errors"].append( + {"kind": "cache_cleanup", "cache_root": str(cache), "error": error} + ) + continue + if not cache.exists(): + results["cache_cleanup"].append({"cache_root": str(cache), "status": "already_removed"}) + continue + artifact = out_dir / f"registered-cache-cleanup-{index}.json" + try: + preserve_native_embedding_evidence(cache, out_dir, f"registered-cache-{index}") + owned_relative = canonical.relative_to(root.parent) + cleanup_proof_cache( + cleanup_cli, + project, + cache, + artifact, + run=cleanup_run, + registered_sidecars=registered_sidecars, + direct_only=True, + owned_delete=lambda _cache_parent, _relative: owned_delete( + root.parent, owned_relative + ), + ) + results["cache_cleanup"].append({"cache_root": str(cache), "status": "removed"}) + except Exception as exc: + error = f"{type(exc).__name__}: {exc}" + results["cache_cleanup"].append( + {"cache_root": str(cache), "status": "failed", "error": error} + ) + results["errors"].append({"kind": "cache_cleanup", "cache_root": str(cache), "error": error}) + for port in ownership.get("ports", []): + if isinstance(port, int) and 0 < port <= 65535: + results["ports"].append({"port": port, "reachable_after_cleanup": port_reachability(port)}) + remaining_ports = [item["port"] for item in results["ports"] if item["reachable_after_cleanup"]] + if remaining_ports: + results["errors"].append( + {"kind": "ports", "error": f"proof-owned ports remained reachable: {remaining_ports}"} + ) + if not results["errors"]: + owned_delete(root.parent, Path(root.name)) + results["root_removed"] = not root.exists() + else: + results["root_removed"] = False + write_json(out_dir / "proof-owned-cleanup.json", results) + if results["errors"]: + raise RuntimeError( + "proof-owned cleanup had failures after attempting every resource: " + + "; ".join(item["error"] for item in results["errors"]) + ) + + def require_retrieval_full(payload: object, layer: str, artifact: Path) -> None: mode = payload.get("retrieval_mode") if isinstance(payload, dict) else None if mode is None and isinstance(payload, dict): @@ -274,13 +1948,6 @@ def require_help(output: str, artifact: Path) -> None: require("codestory-cli" in output, "help", artifact, "codestory-cli --help output missing binary name") -def require_retrieval_index_ready(payload: object, layer: str, artifact: Path) -> None: - require(isinstance(payload, dict), layer, artifact, "retrieval index output is not a JSON object") - for field in ["zoekt_stubbed", "qdrant_stubbed", "scip_stubbed"]: - value = payload.get(field) - require(value is False, layer, artifact, f"{field} is {value!r}, expected False") - - def require_search_full(payload: object, artifact: Path) -> None: shadow = payload.get("retrieval_shadow") if isinstance(payload, dict) else None mode = shadow.get("retrieval_mode") if isinstance(shadow, dict) else None @@ -305,32 +1972,6 @@ def require_packet_ready(payload: object, artifact: Path) -> None: ) -def require_context_ready(payload: object, artifact: Path) -> None: - require(isinstance(payload, dict), "context", artifact, "context output is not a JSON object") - context = payload.get("context") - require(isinstance(context, dict), "context", artifact, "context output missing context object") - retrieval_version = context.get("retrieval_version") - require( - retrieval_version == "sidecar", - "context", - artifact, - f"context retrieval_version is {retrieval_version!r}", - ) - trace = context.get("retrieval_trace") - require(isinstance(trace, dict), "context", artifact, "context output missing context retrieval trace") - steps = trace.get("steps") - require( - isinstance(steps, list) and len(steps) > 0, - "context", - artifact, - "context retrieval trace has no steps", - ) - shadow = trace.get("retrieval_shadow") - require(isinstance(shadow, dict), "context", artifact, "context retrieval trace missing retrieval shadow") - mode = shadow.get("retrieval_mode") - require(mode == "full", "context", artifact, f"context retrieval_shadow.retrieval_mode is {mode!r}") - - def write_stdio_artifact(artifact: Path, transcript: list[dict], stdout: str, stderr_path: Path, extra: dict | None = None) -> None: payload = { "transcript": transcript, @@ -376,41 +2017,275 @@ def terminate_process_tree(process: subprocess.Popen[str]) -> None: process.kill() -def terminate_worker_pid(pid: int) -> None: +def windows_process_api(): + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong] + kernel32.OpenProcess.restype = ctypes.c_void_p + kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + kernel32.WaitForSingleObject.restype = ctypes.c_ulong + kernel32.TerminateProcess.argtypes = [ctypes.c_void_p, ctypes.c_uint] + kernel32.TerminateProcess.restype = ctypes.c_int + kernel32.CloseHandle.argtypes = [ctypes.c_void_p] + kernel32.CloseHandle.restype = ctypes.c_int + return kernel32 + + +def windows_last_error(kernel32) -> int: + return int(getattr(kernel32, "last_error", ctypes.get_last_error())) + + +def process_start_identity_snapshot( + pid: int, + run=subprocess.run, + *, + platform: str | None = None, + system: str | None = None, +) -> tuple[str, str | None]: + platform = platform or os.name + system = system or sys.platform + if pid <= 0: + return "invalid_pid", None + if system.startswith("linux"): + try: + stat_text = Path("/proc", str(pid), "stat").read_text(encoding="utf-8") + except FileNotFoundError: + return "already_exited", None + except OSError: + return "unknown", None + fields = stat_text.rsplit(") ", 1) + start = fields[1].split()[19] if len(fields) == 2 and len(fields[1].split()) > 19 else None + return ("running", f"linux:{start}") if start else ("unknown", None) + if platform == "nt": + script = f'$p=Get-CimInstance Win32_Process -Filter "ProcessId = {pid}" -ErrorAction Stop; if ($null -eq $p) {{ exit 2 }}; $p.CreationDate.ToUniversalTime().Ticks' + command, prefix, gone = ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], "windows:", 2 + else: + command, prefix, gone = ["ps", "-o", "lstart=", "-p", str(pid)], "unix:", 1 + try: + result = run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=2, + check=False, + text=True, + env={**os.environ, "LC_ALL": "C", "TZ": "UTC"}, + ) + except (OSError, subprocess.TimeoutExpired): + return "unknown", None + if result.returncode == gone: + return "already_exited", None + identity = result.stdout.strip() + return ("running", f"{prefix}{identity}") if result.returncode == 0 and identity else ("unknown", None) + + +def require_windows_process_exit(process_handle, pid: int, timeout_ms: int = 10_000, kernel32=None) -> None: + kernel32 = kernel32 or windows_process_api() + wait_result = kernel32.WaitForSingleObject(process_handle, timeout_ms) + if wait_result == 0: + return + if wait_result == 0xFFFFFFFF: + raise RuntimeError(f"could not wait for worker process {pid} (Windows error {windows_last_error(kernel32)})") + raise RuntimeError(f"worker process {pid} did not terminate before cleanup (wait result {wait_result})") + + +def terminate_worker_pid( + pid: int, + diagnostics: dict | None = None, + *, + expected_start_identity: str | None = None, + kernel32=None, + run=subprocess.run, + platform: str | None = None, +) -> None: + diagnostics = diagnostics if diagnostics is not None else {} + platform = platform or os.name + diagnostics.update({"pid": pid, "platform": platform, "attempts": []}) if pid <= 0: + diagnostics["status"] = "invalid_pid" return - if os.name == "nt": + if expected_start_identity is not None: + identity_status, actual_start_identity = process_start_identity_snapshot(pid) + diagnostics["process_identity"] = { + "status": identity_status, + "start_identity": actual_start_identity, + } + if identity_status == "already_exited": + diagnostics["status"] = "already_exited" + return + if identity_status != "running": + raise RuntimeError(f"could not prove worker process {pid} start identity") + if actual_start_identity != expected_start_identity: + raise RuntimeError(f"refusing to terminate reused worker pid {pid}") + if platform == "nt": + kernel32 = kernel32 or windows_process_api() + process_handle = None + open_error = 0 try: - subprocess.run( + process_handle = kernel32.OpenProcess(0x00100001, False, pid) + if not process_handle: + open_error = windows_last_error(kernel32) + except (AttributeError, OSError): + process_handle = None + diagnostics["attempts"].append( + {"kind": "open_process", "success": bool(process_handle), "windows_error": open_error} + ) + if not process_handle: + if open_error == 87: + diagnostics["status"] = "already_exited" + return + raise RuntimeError( + f"could not open worker process {pid} for termination proof (Windows error {open_error})" + ) + taskkill_evidence = "not run" + try: + taskkill = run( ["taskkill", "/PID", str(pid), "/T", "/F"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=2, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, check=False, + text=True, ) - except (OSError, subprocess.TimeoutExpired): - pass + taskkill_evidence = ( + f"exit={taskkill.returncode} stdout={taskkill.stdout.strip()!r} stderr={taskkill.stderr.strip()!r}" + ) + diagnostics["attempts"].append( + { + "kind": "taskkill", + "returncode": taskkill.returncode, + "stdout": taskkill.stdout, + "stderr": taskkill.stderr, + } + ) + except (OSError, subprocess.TimeoutExpired) as exc: + taskkill_evidence = f"{type(exc).__name__}: {exc}" + diagnostics["attempts"].append({"kind": "taskkill", "error": taskkill_evidence}) + try: + try: + require_windows_process_exit(process_handle, pid, kernel32=kernel32) + diagnostics["status"] = "terminated_after_taskkill" + except RuntimeError as wait_error: + diagnostics["attempts"].append({"kind": "initial_wait", "error": str(wait_error)}) + terminated = bool(kernel32.TerminateProcess(process_handle, 1)) + terminate_error = 0 if terminated else windows_last_error(kernel32) + diagnostics["attempts"].append( + {"kind": "terminate_process", "success": terminated, "windows_error": terminate_error} + ) + if not terminated: + try: + require_windows_process_exit(process_handle, pid, timeout_ms=0, kernel32=kernel32) + except RuntimeError: + raise RuntimeError( + f"could not terminate worker process {pid} (Windows error {terminate_error}; " + f"taskkill {taskkill_evidence}; initial wait: {wait_error})" + ) from wait_error + try: + require_windows_process_exit(process_handle, pid, timeout_ms=30_000, kernel32=kernel32) + diagnostics["status"] = "terminated_after_direct_termination" + except RuntimeError as final_wait_error: + diagnostics["attempts"].append({"kind": "final_wait", "error": str(final_wait_error)}) + raise RuntimeError( + f"worker process {pid} remained alive after direct termination; " + f"taskkill {taskkill_evidence}; initial wait: {wait_error}; final wait: {final_wait_error}" + ) from final_wait_error + finally: + kernel32.CloseHandle(process_handle) else: try: os.kill(pid, signal.SIGKILL) - except (ProcessLookupError, PermissionError): - pass - - -def running_status_worker_pids(status: dict) -> set[int]: - operations = status.get("readiness_broker", {}).get("operations", []) - return { - operation["pid"] - for operation in operations - if isinstance(operation, dict) - and operation.get("status") == "running" - and isinstance(operation.get("pid"), int) - } - - -def terminate_status_workers(status: dict) -> None: - for worker_pid in running_status_worker_pids(status): - terminate_worker_pid(worker_pid) + except ProcessLookupError: + diagnostics["status"] = "already_exited" + return + except PermissionError as exc: + raise RuntimeError(f"could not terminate worker process {pid}") from exc + deadline = time.monotonic() + 10 + while True: + try: + waited_pid, _status = os.waitpid(pid, os.WNOHANG) + if waited_pid == pid: + diagnostics["status"] = "terminated" + return + except ChildProcessError: + pass + try: + os.kill(pid, 0) + except ProcessLookupError: + diagnostics["status"] = "terminated" + return + except PermissionError as exc: + raise RuntimeError(f"could not prove worker process {pid} terminated") from exc + if time.monotonic() >= deadline: + raise RuntimeError(f"worker process {pid} did not terminate before cleanup") + time.sleep(0.1) + + +def cleanup_proof_owned_repair_workers( + cache_root: Path, + project: Path, + registered_sidecars: list[dict] | None = None, +) -> tuple[list[dict], list[str]]: + evidence = [] + errors = [] + canonical_cache = cache_root.resolve(strict=False) + identities = registered_sidecars or proof_agent_identities([canonical_cache], project) + for identity in identities: + item = None + try: + if ( + not isinstance(identity, dict) + or Path(str(identity.get("cache_root", ""))).resolve(strict=False) != canonical_cache + ): + continue + path = Path(identity["state_file"]).with_name("ready-repair-enqueue.lock") + if not path.exists(): + continue + if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(canonical_cache): + raise RuntimeError(f"proof ready-repair reservation is unsafe: {path}") + record = read_json_file(path) + if not isinstance(record, dict): + raise RuntimeError(f"proof ready-repair reservation is invalid: {path}") + if record.get("adopted") is not True: + continue + project_root = record.get("project_root") + expected = (identity["project"], identity["profile"], identity["run_id"], identity["namespace"]) + observed = ( + str(Path(project_root).resolve(strict=False)) if isinstance(project_root, str) else None, + record.get("profile"), + record.get("run_id"), + record.get("namespace"), + ) + pid = record.get("pid") + attempt = record.get("token") + start = record.get("process_start_identity") + if observed != expected or not isinstance(pid, int) or pid <= 0: + raise RuntimeError("proof ready-repair reservation ownership changed") + if not isinstance(attempt, str) or not attempt or not isinstance(start, str) or not start: + raise RuntimeError("proof ready-repair reservation process identity is incomplete") + item = { + "kind": "ready_repair_worker_cleanup", + "pid": pid, + "attempt_id": attempt, + "process_start_identity": start, + "source": path.name, + } + evidence.append(item) + terminate_worker_pid( + pid, + item, + expected_start_identity=start, + ) + except Exception as exc: + error = f"{type(exc).__name__}: {exc}" + if item is None: + evidence.append({ + "kind": "ready_repair_worker_validation", + "state_file": str(identity.get("state_file", "")) if isinstance(identity, dict) else "", + "error": error, + }) + else: + item["error"] = error + errors.append(error) + return evidence, errors def read_stdio_line(stdout_queue: queue.Queue[str | None], timeout_secs: int) -> str | None: @@ -459,7 +2334,7 @@ def plugin_stdio_status( require_plugin_manifest_version(plugin_root, expected_version) launcher = plugin_root / "scripts" / "codestory-mcp.cjs" require(launcher.is_file(), "plugin_stdio", artifact, f"plugin launcher is missing: {launcher}") - with tempfile.TemporaryDirectory(prefix="codestory-plugin-data-", dir=artifact.parent) as data: + with temporary_directory_with_retry("codestory-plugin-data-", artifact.parent) as data: return stdio_status_command( ["node", str(launcher)], artifact, @@ -467,17 +2342,90 @@ def plugin_stdio_status( project, layer="plugin_stdio", cwd=project, - env={ + env=proof_environment({ **os.environ, "CODESTORY_CLI": "", "CODESTORY_CACHE_ROOT": str(cache_root), "CODESTORY_PLUGIN_RELEASE_DIR": str(release_dir), "PLUGIN_DATA": data, - }, + }), cleanup_status_workers=True, ) +def managed_convergence_request_plan( + project: Path, + require_ready: bool, + question: str, + query: str, +) -> dict: + activation = { + "jsonrpc": "2.0", + "id": "ground_activation", + "method": "tools/call", + "params": { + "name": "ground", + "arguments": {"project": str(project), "budget": "strict"}, + }, + } + if not require_ready: + return { + "extra_requests": [ + activation, + *[ + { + "jsonrpc": "2.0", + "id": f"setup_poll_{index}", + "method": "tools/call", + "params": { + "name": "sidecar_setup", + "arguments": {"project": str(project), "action": "status"}, + }, + "_delay_before_secs": 2, + } + for index in range(1, 6) + ], + { + "jsonrpc": "2.0", + "id": "status_after_convergence", + "method": "resources/read", + "params": {"uri": STATUS_URI, "project": str(project)}, + }, + ] + } + return { + "extra_requests": [activation], + "poll_request": { + "jsonrpc": "2.0", + "id": "convergence_status", + "method": "resources/read", + "params": {"uri": STATUS_URI, "project": str(project)}, + }, + "poll_until": managed_status_response_is_ready, + "poll_interval_secs": 2, + "post_poll_requests": [ + { + "jsonrpc": "2.0", + "id": "packet_after_convergence", + "method": "tools/call", + "params": { + "name": "packet", + "arguments": {"project": str(project), "question": question, "budget": "compact"}, + }, + }, + { + "jsonrpc": "2.0", + "id": "search_after_convergence", + "method": "tools/call", + "params": { + "name": "search", + "arguments": {"project": str(project), "query": query, "why": True}, + }, + }, + ], + } + + def plugin_stdio_handoff( plugin_root: Path, release_dir: Path, @@ -487,12 +2435,98 @@ def plugin_stdio_handoff( timeout_secs: int, expected_version: str, archive_cli: Path, + model_dir: Path, + archive: Path, + *, + require_ready: bool = False, + question: str = DEFAULT_QUESTION, + query: str = DEFAULT_QUERY, ) -> dict: require_plugin_manifest_version(plugin_root, expected_version) launcher = plugin_root / "scripts" / "codestory-mcp.cjs" - require(launcher.is_file(), "managed_plugin_handoff", artifact, f"plugin launcher is missing: {launcher}") - with tempfile.TemporaryDirectory(prefix="codestory-plugin-data-", dir=artifact.parent) as data: + require( + launcher.is_file(), + "managed_plugin_convergence", + artifact, + f"plugin launcher is missing: {launcher}", + ) + with temporary_directory_with_retry("codestory-plugin-data-", artifact.parent) as data: plugin_data = Path(data) + prior_version = "0.0.0" + archive_suffix = archive.name.removeprefix(f"codestory-cli-v{expected_version}-") + extension = ".zip" if archive_suffix.endswith(".zip") else ".tar.gz" + target = archive_suffix.removesuffix(extension) + require( + target and target != archive_suffix, + "managed_plugin_convergence", + artifact, + f"could not derive release target from {archive.name}", + ) + prior_dir = plugin_data / "codestory-cli" / prior_version + prior_bin = prior_dir / "bin" / ( + "codestory-cli.exe" if os.name == "nt" else "codestory-cli" + ) + prior_bin.parent.mkdir(parents=True) + if os.name == "nt": + prior_source = prior_dir / "rollback-version.rs" + prior_source.write_text( + f'''fn main() {{ + let args = std::env::args().skip(1).collect::>(); + if args.len() == 1 && args[0] == "--version" {{ + println!("codestory-cli {prior_version}"); + return; + }} + std::process::exit(90); +}} +''', + encoding="utf-8", + ) + compiled = subprocess.run( + ["rustc", str(prior_source), "-o", str(prior_bin)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout_secs, + check=False, + ) + require( + compiled.returncode == 0 and prior_bin.is_file(), + "managed_plugin_convergence", + artifact, + f"could not compile native rollback fixture: {compiled.stderr}", + ) + pe = prior_bin.read_bytes() + pe_offset = int.from_bytes(pe[0x3C:0x40], "little") + machine = int.from_bytes(pe[pe_offset + 4 : pe_offset + 6], "little") + require( + machine == {"windows-x64": 0x8664, "windows-arm64": 0xAA64}.get(target), + "managed_plugin_convergence", + artifact, + f"native rollback fixture machine 0x{machine:04x} did not match {target}", + ) + else: + prior_bin.write_text( + f"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'codestory-cli {prior_version}'; exit 0; fi\nexit 90\n", + encoding="utf-8", + ) + prior_bin.chmod(prior_bin.stat().st_mode | stat.S_IXUSR) + prior_archive = f"codestory-cli-v{prior_version}-{target}{extension}" + write_json( + prior_dir / "manifest.json", + { + "path": prior_bin.relative_to(prior_dir).as_posix(), + "sha256": sha256_file(prior_bin), + "version": prior_version, + "build_source": "github_release", + "repo_ref": f"v{prior_version}", + "archive": prior_archive, + "archive_url": (release_dir / prior_archive).resolve().as_uri(), + "archive_sha256": "0" * 64, + "target": target, + "provisioned_at": "1970-01-01T00:00:00.000Z", + "stdio_initialize_verified": True, + }, + ) policy_path = plugin_data / "sidecar-setup-policy.json" policy_artifact = artifact.with_name("managed-plugin-policy.json") try: @@ -515,7 +2549,7 @@ def plugin_stdio_handoff( "stderr": captured_text(getattr(exc, "stderr", None)), }, ) - fail("managed_plugin_handoff", policy_artifact, f"plugin sidecar policy enable failed: {exc}") + fail("managed_plugin_convergence", policy_artifact, f"plugin sidecar policy enable failed: {exc}") write_json( policy_artifact, {"returncode": policy.returncode, "stdout": policy.stdout, "stderr": policy.stderr}, @@ -524,46 +2558,83 @@ def plugin_stdio_handoff( policy.returncode == 0 and policy_path.is_file() and read_json_file(policy_path).get("state") == "enabled", - "managed_plugin_handoff", + "managed_plugin_convergence", policy_artifact, "plugin sidecar policy enable failed", ) - status_after = { - "jsonrpc": "2.0", - "id": "status_after_repair", - "method": "resources/read", - "params": {"uri": STATUS_URI, "project": str(project)}, - } + plugin_env = proof_environment({ + **os.environ, + "CODESTORY_CLI": "", + "CODESTORY_CACHE_ROOT": str(cache_root), + "CODESTORY_EMBED_MODEL_DIR": str(model_dir), + "CODESTORY_PLUGIN_RELEASE_DIR": str(release_dir), + "CODESTORY_PLUGIN_SIDECAR_POLICY_PATH": str(policy_path), + "PLUGIN_DATA": str(plugin_data), + }) + ownership_before_status = proof_ownership_snapshot(cache_root) + observational_artifact = artifact.with_name("managed-convergence-status-observational.json") + observational_status = stdio_status_command( + ["node", str(launcher)], + observational_artifact, + timeout_secs, + project, + layer="managed_plugin_convergence", + cwd=project, + env=plugin_env, + ) + ownership_after_status = proof_ownership_snapshot(cache_root) + require( + ownership_after_status == ownership_before_status, + "managed_plugin_convergence", + observational_artifact, + "observational status changed local-refresh or ready-repair ownership files", + ) + require_managed_observational_status( + observational_status, + observational_artifact, + expected_version, + archive_cli, + ) + request_plan = managed_convergence_request_plan(project, require_ready, question, query) status = stdio_status_command( ["node", str(launcher)], artifact, timeout_secs, project, - layer="managed_plugin_handoff", + layer="managed_plugin_convergence", cwd=project, - env={ - **os.environ, - "CODESTORY_CLI": "", - "CODESTORY_CACHE_ROOT": str(cache_root), - "CODESTORY_PLUGIN_RELEASE_DIR": str(release_dir), - "CODESTORY_PLUGIN_SIDECAR_POLICY_PATH": str(policy_path), - "PLUGIN_DATA": str(plugin_data), - }, - extra_requests=[ - { - "jsonrpc": "2.0", - "id": "repair", - "method": "tools/call", - "params": { - "name": "sidecar_setup", - "arguments": {"project": str(project), "action": "repair"}, - }, - }, - status_after, - ], + env=plugin_env, + **request_plan, cleanup_status_workers=True, ) - require_managed_plugin_handoff(status, artifact, expected_version, archive_cli) + if require_ready: + status = require_managed_plugin_ready_convergence( + status, + artifact, + expected_version, + archive_cli, + ) + else: + require_managed_plugin_convergence(status, artifact, expected_version, archive_cli) + retention = status.get("plugin_runtime", {}).get("managed_cli_retention", {}) + retained = retention.get("retained", []) if isinstance(retention, dict) else [] + require( + retention.get("active_version") == expected_version + and any(item.get("version") == prior_version and item.get("reason") == "rollback" for item in retained), + "managed_plugin_convergence", + artifact, + "managed upgrade did not retain the verified prior version as rollback", + ) + write_json( + artifact.with_name("managed-plugin-upgrade.json"), + { + "prior_version": prior_version, + "requested_version": expected_version, + "server_version": status.get("server_version"), + "server_executable": status.get("server_executable"), + "retention": retention, + }, + ) return status @@ -576,8 +2647,14 @@ def stdio_status_command( cwd: Path | None = None, env: dict[str, str] | None = None, extra_requests: list[dict] | None = None, + poll_request: dict | None = None, + poll_until: Callable[[dict], bool] | None = None, + poll_interval_secs: float = 1.0, + post_poll_requests: list[dict] | None = None, cleanup_status_workers: bool = False, ) -> dict: + if (poll_request is None) != (poll_until is None): + raise ValueError("poll_request and poll_until must be provided together") stderr_path = artifact.with_suffix(artifact.suffix + ".stderr.txt") process = subprocess.Popen( command, @@ -614,78 +2691,184 @@ def stdio_status_command( stderr_thread.start() responses: list[dict] = [] process_terminated = False - try: - for request in requests: - entry = {"request": request} - transcript.append(entry) - process.stdin.write(json.dumps(request) + "\n") - process.stdin.flush() + failure_pending = False + failure_extra: dict | None = None + + def stdio_fail(message: str, extra: dict | None = None) -> None: + nonlocal failure_pending, failure_extra + failure_pending = True + failure_extra = extra + fail(layer, artifact, message) + + def read_correlated_response(expected_id: str | int, entry: dict, phase: str) -> dict: + deadline = time.monotonic() + timeout_secs + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + entry["timed_out"] = True + stdio_fail(f"stdio MCP {phase} timed out after {timeout_secs}s", {"timed_out": True}) try: - line = read_stdio_line(stdout_queue, timeout_secs) + line = read_stdio_line(stdout_queue, remaining) except subprocess.TimeoutExpired: entry["timed_out"] = True - terminate_process_tree(process) - process_terminated = True - stderr_path.write_text("".join(stderr_lines), encoding="utf-8") - write_stdio_artifact( - artifact, - transcript, - "".join(stdout_lines), - stderr_path, - {"timed_out": True, "timeout_secs": timeout_secs}, - ) - fail(layer, artifact, f"stdio MCP request timed out after {timeout_secs}s") + stdio_fail(f"stdio MCP {phase} timed out after {timeout_secs}s", {"timed_out": True}) if line is None: - terminate_process_tree(process) - process_terminated = True - stderr_path.write_text("".join(stderr_lines), encoding="utf-8") - write_stdio_artifact(artifact, transcript, "".join(stdout_lines), stderr_path) - fail(layer, artifact, "stdio MCP server closed before responding") + stdio_fail(f"stdio MCP server closed during {phase}") try: response = json.loads(line) except json.JSONDecodeError as exc: entry["invalid_line"] = line - terminate_process_tree(process) - process_terminated = True - stderr_path.write_text("".join(stderr_lines), encoding="utf-8") - write_stdio_artifact( - artifact, - transcript, - "".join(stdout_lines), - stderr_path, - {"invalid_line": line}, + stdio_fail(f"stdio MCP {phase} emitted invalid JSON: {exc}", {"invalid_line": line}) + if not isinstance(response, dict): + entry["invalid_response"] = response + stdio_fail(f"stdio MCP {phase} emitted a non-object response", {"invalid_response": response}) + if response.get("jsonrpc") != "2.0": + entry["invalid_response"] = response + stdio_fail(f"stdio MCP {phase} response has invalid jsonrpc envelope", {"invalid_response": response}) + if "method" in response: + method = response.get("method") + if isinstance(method, str) and method.startswith("notifications/") and "id" not in response: + transcript.append({"notification": response}) + continue + entry["unexpected_server_message"] = response + stdio_fail(f"stdio MCP {phase} emitted an unexpected server request", {"unexpected_server_message": response}) + if response.get("id") != expected_id: + entry["unexpected_response"] = response + stdio_fail( + f"stdio MCP {phase} returned id {response.get('id')!r}, expected {expected_id!r}", + {"unexpected_response": response}, ) - fail(layer, artifact, f"stdio MCP server emitted invalid JSON: {exc}") - entry["response"] = response - responses.append(response) + if ("result" in response) == ("error" in response): + entry["invalid_response"] = response + stdio_fail(f"stdio MCP {phase} response must contain exactly one of result or error", {"invalid_response": response}) + return response + + def send_request(request_spec: dict) -> dict: + request = dict(request_spec) + delay_before_secs = request.pop("_delay_before_secs", 0) + if delay_before_secs: + time.sleep(delay_before_secs) + entry = {"request": request} + transcript.append(entry) + process.stdin.write(json.dumps(request) + "\n") + process.stdin.flush() + response = read_correlated_response(request["id"], entry, f"request {request['id']}") + entry["response"] = response + responses.append(response) + return response + + try: + initialize = { + "jsonrpc": "2.0", + "id": "initialize", + "method": "initialize", + "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "packaged-proof", "version": "1"}}, + } + initialize_entry = {"request": initialize} + transcript.append(initialize_entry) + process.stdin.write(json.dumps(initialize) + "\n") + process.stdin.flush() + initialize_response = read_correlated_response("initialize", initialize_entry, "initialize") + initialize_entry["response"] = initialize_response + responses.append(initialize_response) + initialize_result = initialize_response.get("result") + if "error" in initialize_response: + stdio_fail(f"stdio MCP initialize failed: {initialize_response.get('error')}") + if not isinstance(initialize_result, dict): + stdio_fail("stdio MCP initialize missing result") + if initialize_result.get("protocolVersion") != initialize["params"]["protocolVersion"]: + stdio_fail(f"stdio MCP initialize protocol is {initialize_result.get('protocolVersion')!r}") + if not isinstance(initialize_result.get("capabilities"), dict): + stdio_fail("stdio MCP initialize missing capabilities") + if not isinstance(initialize_result["capabilities"].get("tools"), dict): + stdio_fail("stdio MCP initialize capabilities.tools must be an object") + initialized = {"jsonrpc": "2.0", "method": "notifications/initialized"} + transcript.append({"request": initialized}) + process.stdin.write(json.dumps(initialized) + "\n") + process.stdin.flush() + + list_changed = initialize_result.get("capabilities", {}).get("tools", {}).get("listChanged") + if list_changed is True: + publication_deadline = time.monotonic() + timeout_secs + while True: + remaining = publication_deadline - time.monotonic() + if remaining <= 0: + stdio_fail(f"stdio MCP runtime publication timed out after {timeout_secs}s", {"timed_out": True}) + try: + notification_line = read_stdio_line(stdout_queue, remaining) + except subprocess.TimeoutExpired: + stdio_fail(f"stdio MCP runtime publication timed out after {timeout_secs}s", {"timed_out": True}) + if notification_line is None: + stdio_fail("stdio MCP server closed before runtime publication") + try: + notification = json.loads(notification_line) + except json.JSONDecodeError as exc: + stdio_fail(f"stdio MCP runtime publication emitted invalid JSON: {exc}", {"invalid_line": notification_line}) + if not isinstance(notification, dict): + stdio_fail("stdio MCP runtime publication emitted a non-object message", {"invalid_response": notification}) + method = notification.get("method") + if ( + notification.get("jsonrpc") != "2.0" + or "id" in notification + or not isinstance(method, str) + or not method.startswith("notifications/") + ): + stdio_fail("stdio MCP runtime publication emitted an unexpected message", {"unexpected_response": notification}) + transcript.append({"notification": notification}) + if method == "notifications/tools/list_changed": + break + + for request_spec in requests: + send_request(request_spec) + + if poll_request is not None and poll_until is not None: + poll_deadline = time.monotonic() + timeout_secs + poll_id = str(poll_request.get("id", "poll")) + attempt = 0 + while True: + if attempt: + remaining = poll_deadline - time.monotonic() + if remaining <= 0: + stdio_fail(f"stdio MCP poll {poll_id} timed out after {timeout_secs}s", {"timed_out": True}) + time.sleep(min(poll_interval_secs, remaining)) + attempt += 1 + request = {**poll_request, "id": f"{poll_id}_{attempt}"} + if poll_until(send_request(request)): + break + if time.monotonic() >= poll_deadline: + stdio_fail(f"stdio MCP poll {poll_id} timed out after {timeout_secs}s", {"timed_out": True}) + + for request_spec in post_poll_requests or []: + send_request(request_spec) finally: - if not process_terminated: - terminate_process_tree(process) - process_terminated = True - worker_pids: set[int] = set() - for response in responses: - if not isinstance(response, dict): - continue - if response.get("id") == "repair": - worker_pid = response.get("result", {}).get("structuredContent", {}).get("pid") - if isinstance(worker_pid, int): - worker_pids.add(worker_pid) - if cleanup_status_workers: - status = status_from_resource_response(response) - if isinstance(status, dict): - worker_pids.update(running_status_worker_pids(status)) - for worker_pid in worker_pids: - terminate_worker_pid(worker_pid) try: process.stdin.close() except OSError: pass - try: - process.wait(timeout=0.5) - except subprocess.TimeoutExpired: + if not process_terminated: + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + terminate_process_tree(process) + process_terminated = True + worker_cleanup_artifact = artifact.with_name(f"{artifact.stem}-worker-cleanup.json") + cache_value = (env or os.environ).get("CODESTORY_CACHE_ROOT") + worker_cleanup, worker_errors = ( + cleanup_proof_owned_repair_workers(Path(cache_value), project) + if cleanup_status_workers and cache_value + else ([], []) + ) + write_json(worker_cleanup_artifact, {"workers": worker_cleanup}) + if worker_errors: + raise RuntimeError("proof-owned repair worker cleanup failed: " + "; ".join(worker_errors)) + if process.poll() is None: process.kill() - stdout_thread.join(timeout=0.2) - stderr_thread.join(timeout=0.2) + process.wait(timeout=2) + stdout_thread.join(timeout=2) + stderr_thread.join(timeout=2) + if failure_pending: + stderr_path.write_text("".join(stderr_lines), encoding="utf-8") + write_stdio_artifact(artifact, transcript, "".join(stdout_lines), stderr_path, failure_extra) stderr_path.write_text("".join(stderr_lines), encoding="utf-8") responses_by_id = {response.get("id"): response for response in responses if isinstance(response, dict)} @@ -864,13 +3047,43 @@ def status_from_resource_response(response: dict) -> dict | None: return status if isinstance(status, dict) else None -def require_managed_plugin_handoff( +def resource_statuses(responses: list[dict], request_prefix: str) -> list[dict]: + return [ + status + for response in responses + if str(response.get("id", "")).startswith(request_prefix) + and isinstance(status := status_from_resource_response(response), dict) + ] + + +def managed_status_is_ready(status: dict) -> bool: + surfaces = status.get("allowed_surfaces", {}) + proof = status.get("readiness_broker", {}).get("gpu_proof", {}) + return ( + local_freshness_status(status) == "fresh" + and status.get("retrieval_mode") == "full" + and surfaces.get("packet", {}).get("allowed") is True + and surfaces.get("search", {}).get("allowed") is True + and proof.get("proof_status") == "verified" + and proof.get("meaningful_accelerator_work_proven") is True + and proof.get("embed_smoke_ok") is True + and proof.get("requested_provider") == "metal" + and proof.get("detected_provider") == "metal" + ) + + +def managed_status_response_is_ready(response: dict) -> bool: + status = status_from_resource_response(response) + return isinstance(status, dict) and managed_status_is_ready(status) + + +def require_managed_binary_provenance( status: dict, artifact: Path, expected_version: str, archive_cli: Path, ) -> None: - require_plugin_provenance(status, artifact, expected_version, "managed_plugin_handoff") + require_plugin_provenance(status, artifact, expected_version, "managed_plugin_convergence") plugin_runtime = status["plugin_runtime"] managed_binary = Path(plugin_runtime.get("managed_binary_path", "")) server_executable = Path(status.get("server_executable", "")) @@ -878,7 +3091,7 @@ def require_managed_plugin_handoff( managed_binary.is_file() and server_executable.is_file() and managed_binary.samefile(server_executable), - "managed_plugin_handoff", + "managed_plugin_convergence", artifact, "managed_binary_path does not identify the executable serving MCP", ) @@ -886,77 +3099,314 @@ def require_managed_plugin_handoff( server_sha256 = sha256_file(server_executable) require( archive_sha256 == server_sha256 == status.get("server_executable_sha256"), - "managed_plugin_handoff", + "managed_plugin_convergence", artifact, "managed MCP executable does not match the packaged archive binary", ) + + +def local_freshness_status(status: dict) -> str | None: + for name in ("effective_index_freshness", "index_freshness"): + value = status.get(name, {}).get("status") + if isinstance(value, str): + return value + return None + + +def structured_content_from_response(response: dict) -> dict | None: + content = response.get("result", {}).get("structuredContent") + return content if isinstance(content, dict) else None + + +def require_managed_observational_status( + status: dict, + artifact: Path, + expected_version: str, + archive_cli: Path, +) -> None: + require_managed_binary_provenance(status, artifact, expected_version, archive_cli) + require( + local_freshness_status(status) == "stale", + "managed_plugin_convergence", + artifact, + f"status did not observe the seeded project as stale: {local_freshness_status(status)!r}", + ) + require( + isinstance(status.get("index_publication", {}).get("generation"), int), + "managed_plugin_convergence", + artifact, + "stale status did not retain a complete published generation", + ) + require( + not status.get("readiness_broker", {}).get("operations"), + "managed_plugin_convergence", + artifact, + "observational status reported a running readiness operation", + ) + setup = status.get("sidecar_setup", {}) + require( + setup.get("active_repair") is None + and setup.get("last_worker_result") is None + and status.get("status_resource_auto_repair") is None, + "managed_plugin_convergence", + artifact, + "observational status started or reported a repair attempt", + ) + require( + status.get("allowed_surfaces", {}).get("packet", {}).get("allowed") is False + and status.get("allowed_surfaces", {}).get("search", {}).get("allowed") is False, + "managed_plugin_convergence", + artifact, + "missing sidecars did not keep packet/search fail closed", + ) + + +def require_single_managed_repair( + setup_snapshots: list[dict], + expected_project: str | None, + expected_outcomes: set[str], + artifact: Path, +) -> dict: + records = [ + record + for setup in setup_snapshots + for record in (setup.get("active_repair"), setup.get("last_worker_result")) + if isinstance(record, dict) + and isinstance(record.get("attempt_id"), str) + and record.get("attempt_id") + ] + attempt_ids = {record["attempt_id"] for record in records} + namespaces = [record.get("namespace") for record in records] + require( + len(attempt_ids) == 1, + "managed_plugin_convergence", + artifact, + f"ground activation did not retain exactly one repair attempt: {sorted(attempt_ids)}", + ) + require( + isinstance(expected_project, str) + and expected_project + and all( + record.get("project_root") == expected_project + and record.get("profile") == "agent" + and record.get("run_id") == "shared-agent" + and isinstance(record.get("namespace"), str) + and record.get("namespace") + for record in records + ) + and len(set(namespaces)) == 1, + "managed_plugin_convergence", + artifact, + "ground activation repair records did not preserve one durable project/profile/run/namespace identity", + ) + terminal = next( + ( + setup.get("last_worker_result") + for setup in reversed(setup_snapshots) + if isinstance(setup.get("last_worker_result"), dict) + and setup.get("active_repair") is None + ), + None, + ) + require( + isinstance(terminal, dict) + and terminal.get("attempt_id") in attempt_ids + and terminal.get("run_id") == "shared-agent" + and terminal.get("outcome") in expected_outcomes, + "managed_plugin_convergence", + artifact, + f"automatic shared-agent repair did not finish with an expected outcome: {sorted(expected_outcomes)}", + ) + return terminal + + +def require_managed_ground_activation( + status_before: dict, + artifact: Path, + expected_version: str, + archive_cli: Path, +) -> int: + require_managed_observational_status(status_before, artifact, expected_version, archive_cli) + initial_generation = status_before["index_publication"]["generation"] + ground = transcript_response(artifact, "ground_activation") or {} + require( + structured_content_from_response(ground) is not None + and ground.get("result", {}).get("isError") is not True, + "managed_plugin_convergence", + artifact, + "ground activation did not serve a complete publication", + ) + return initial_generation + + +def require_managed_generation_advanced( + status_after: dict | None, + initial_generation: int, + artifact: Path, + expected_version: str, + archive_cli: Path, +) -> dict: require( - status.get("allowed_surfaces", {}).get("ground", {}).get("allowed") is True, - "managed_plugin_handoff", + isinstance(status_after, dict), + "managed_plugin_convergence", artifact, - "managed plugin status did not allow local ground", + "status after ground activation was unavailable", ) - repair_response = transcript_response(artifact, "repair") or {} - repair = repair_response.get("result", {}).get("structuredContent") - require(isinstance(repair, dict), "managed_plugin_handoff", artifact, "repair response missing structuredContent") - repair_status = repair.get("status") + require_managed_binary_provenance(status_after, artifact, expected_version, archive_cli) require( - repair_status in {"started", "already_running"}, - "managed_plugin_handoff", + local_freshness_status(status_after) == "fresh" + and isinstance(status_after.get("index_publication", {}).get("generation"), int) + and status_after["index_publication"]["generation"] > initial_generation, + "managed_plugin_convergence", + artifact, + "ground activation did not publish a newer complete local generation", + ) + return status_after + + +def require_managed_plugin_convergence( + status_before: dict, + artifact: Path, + expected_version: str, + archive_cli: Path, +) -> None: + initial_generation = require_managed_ground_activation( + status_before, artifact, - f"repair status is {repair_status!r}, expected started or already_running", + expected_version, + archive_cli, ) - if repair_status == "started": + setup_snapshots = [] + for request_id in ( + "setup_poll_1", + "setup_poll_2", + "setup_poll_3", + "setup_poll_4", + "setup_poll_5", + ): + setup = structured_content_from_response(transcript_response(artifact, request_id) or {}) require( - isinstance(repair.get("attempt_id"), str) - and isinstance(repair.get("pid"), int) - and repair.get("reservation_published") is True, - "managed_plugin_handoff", + isinstance(setup, dict), + "managed_plugin_convergence", artifact, - "started repair did not publish an attempt reservation", + f"{request_id} did not return sidecar setup status", ) - repair_setup = repair.get("sidecar_setup") or {} - attempt_id = repair.get("attempt_id") or (repair_setup.get("active_repair") or {}).get("attempt_id") - require( - isinstance(attempt_id, str) and len(attempt_id) > 0, - "managed_plugin_handoff", + setup_snapshots.append(setup) + require_single_managed_repair( + setup_snapshots, + status_before.get("project_root"), + {"failed", "succeeded"}, artifact, - "repair response did not identify its attempt", ) - next_calls = repair.get("recommended_next_calls", []) + status_after = status_from_resource_response( + transcript_response(artifact, "status_after_convergence") or {} + ) + status_after = require_managed_generation_advanced( + status_after, + initial_generation, + artifact, + expected_version, + archive_cli, + ) + gpu_proof = status_after.get("readiness_broker", {}).get("gpu_proof", {}) require( - any( - isinstance(call, dict) - and call.get("method") == "tools/call" - and call.get("tool") == "status" - and call.get("arguments", {}).get("project") - for call in next_calls + status_after.get("allowed_surfaces", {}).get("packet", {}).get("allowed") is False + and status_after.get("allowed_surfaces", {}).get("search", {}).get("allowed") is False + and ( + gpu_proof.get("proof_status") != "verified" + or gpu_proof.get("meaningful_accelerator_work_proven") is not True + or gpu_proof.get("embed_smoke_ok") is not True ), - "managed_plugin_handoff", + "managed_plugin_convergence", + artifact, + "accelerator-required packet/search opened without verified runtime smoke proof", + ) + + +def require_managed_plugin_ready_convergence( + status_before: dict, + artifact: Path, + expected_version: str, + archive_cli: Path, +) -> dict: + initial_generation = require_managed_ground_activation( + status_before, artifact, - "repair response did not point back to project-scoped status", + expected_version, + archive_cli, ) - status_after_response = transcript_response(artifact, "status_after_repair") or {} - status_after = status_from_resource_response(status_after_response) + + transcript = read_json_file(artifact).get("transcript", []) + tool_names = [ + entry.get("request", {}).get("params", {}).get("name") + for entry in transcript + if entry.get("request", {}).get("method") == "tools/call" + ] require( - isinstance(status_after, dict), - "managed_plugin_handoff", + tool_names == ["ground", "packet", "search"], + "managed_plugin_convergence", artifact, - "status read after repair handoff failed", + f"grounding-only proof invoked unexpected MCP tools: {tool_names}", ) - require_plugin_provenance(status_after, artifact, expected_version, "managed_plugin_handoff") - setup = status_after.get("sidecar_setup", {}) - observed_attempts = { - (setup.get("active_repair") or {}).get("attempt_id"), - (setup.get("last_worker_result") or {}).get("attempt_id"), - } + status_snapshots = resource_statuses( + [entry.get("response", {}) for entry in transcript], + "convergence_status_", + ) + require( + status_snapshots and managed_status_is_ready(status_snapshots[-1]), + "managed_plugin_convergence", + artifact, + "grounding activation did not reach full verified packet/search readiness", + ) + + require_single_managed_repair( + [status.get("sidecar_setup", {}) for status in status_snapshots], + status_before.get("project_root"), + {"succeeded"}, + artifact, + ) + + for status in [status_before, *status_snapshots]: + proof = status.get("readiness_broker", {}).get("gpu_proof", {}) + if proof.get("proof_status") == "verified" and proof.get("embed_smoke_ok") is True: + continue + surfaces = status.get("allowed_surfaces", {}) + require( + surfaces.get("packet", {}).get("allowed") is False + and surfaces.get("search", {}).get("allowed") is False, + "managed_plugin_convergence", + artifact, + "packet/search opened before verified GPU and embedding proof", + ) + + status_after = require_managed_generation_advanced( + status_snapshots[-1], + initial_generation, + artifact, + expected_version, + archive_cli, + ) + proof = status_after.get("readiness_broker", {}).get("gpu_proof", {}) + launch = proof.get("runtime_identity", {}).get("embedding_launch") + resource = status_after.get("readiness_broker", {}).get("resources", {}).get("native_embedding_runtime", {}) require( - attempt_id in observed_attempts, - "managed_plugin_handoff", + managed_status_is_ready(status_after) + and proof.get("observation_source") == "native_log" + and isinstance(launch, dict) + and launch.get("launch_mode") == "native_spawned" + and isinstance(launch.get("pid"), int) + and resource.get("owner_pid") == launch.get("pid"), + "managed_plugin_convergence", artifact, - f"repair attempt {attempt_id!r} was not observable in post-handoff status", + "full retrieval did not retain verified matching native Metal runtime identity", ) + packet = structured_content_from_response(transcript_response(artifact, "packet_after_convergence") or {}) + search = structured_content_from_response(transcript_response(artifact, "search_after_convergence") or {}) + require_packet_ready(packet, artifact) + require_search_full(search, artifact) + return status_after + def run_gate(args: argparse.Namespace) -> None: archive = Path(args.archive).resolve() @@ -972,24 +3422,60 @@ def run_gate(args: argparse.Namespace) -> None: tempfile.TemporaryDirectory(prefix="codestory-packaged-agent-proof-", dir=out_dir) as temp, contextlib.ExitStack() as cleanup_stack, ): + source_project = project + grounding_convergence = getattr(args, "managed_plugin_grounding_convergence", False) + if grounding_convergence: + edge_project = Path(temp) / "CodeStory managed grounding convergence" + shutil.copytree( + source_project, + edge_project, + symlinks=True, + ignore=shutil.ignore_patterns(".git", "target"), + ) + project = edge_project.resolve(strict=True) unpacked = Path(temp) / "unpacked" unpacked.mkdir() unpack_archive(archive, unpacked) cli = find_cli(unpacked) plugin_skill = find_plugin_skill(unpacked) - cache_root = Path(tempfile.mkdtemp(prefix="codestory-packaged-proof-cache-")) - cleanup_stack.callback(cleanup_proof_cache, cli, project, cache_root) - proof_env = {**os.environ, "CODESTORY_CACHE_ROOT": str(cache_root)} - stdio_cache_root = Path(tempfile.mkdtemp(prefix="codestory-packaged-stdio-cache-")) - cleanup_stack.callback(cleanup_proof_cache, cli, project, stdio_cache_root) + cache_root = Path(tempfile.mkdtemp(prefix="codestory-packaged-proof-cache-")).resolve(strict=True) + proof_cleanup_artifact = out_dir / "proof-cache-cleanup.json" + proof_cleanup_sidecars = proof_agent_identities([cache_root], project) + cleanup_stack.push( + cleanup_proof_cache_on_exit( + cli, + project, + cache_root, + proof_cleanup_artifact, + registered_sidecars=proof_cleanup_sidecars, + ) + ) + proof_env = proof_environment({**os.environ, "CODESTORY_CACHE_ROOT": str(cache_root)}) + stdio_cache_root = Path(tempfile.mkdtemp(prefix="codestory-packaged-stdio-cache-")).resolve(strict=True) + stdio_cleanup_artifact = out_dir / "stdio-cache-cleanup.json" + stdio_cleanup_sidecars = proof_agent_identities([stdio_cache_root], project) + cleanup_stack.push( + cleanup_proof_cache_on_exit( + cli, + project, + stdio_cache_root, + stdio_cleanup_artifact, + registered_sidecars=stdio_cleanup_sidecars, + ) + ) stdio_env = {**os.environ, "CODESTORY_CACHE_ROOT": str(stdio_cache_root)} + register_proof_temp_ownership(project, [cache_root, stdio_cache_root], archive) summary = { "archive": str(archive), "cli": str(cli), "plugin_skill": str(plugin_skill), "project": str(project), - "artifacts": {}, + "source_project": str(source_project), + "artifacts": { + "proof_cache_cleanup": str(proof_cleanup_artifact), + "stdio_cache_cleanup": str(stdio_cleanup_artifact), + }, } if args.checksum_file: summary["artifacts"]["checksum"] = str(checksum_artifact) @@ -1008,196 +3494,373 @@ def run_gate(args: argparse.Namespace) -> None: write_json(out_dir / "summary.json", summary) stdio_artifact = out_dir / "serve-stdio-status.json" - if args.version_only or args.managed_plugin_handoff: + if args.version_only or args.managed_plugin_handoff or getattr(args, "intel_runtime_policy", False): stdio_status_payload = stdio_status( cli, project, stdio_artifact, args.timeout_secs, - stdio_env, - cleanup_status_workers=True, + stdio_env, + cleanup_status_workers=True, + ) + require_stdio_shape(stdio_status_payload, stdio_artifact, args.expected_version) + summary["artifacts"]["serve_stdio"] = str(stdio_artifact) + write_json(out_dir / "summary.json", summary) + + if args.version_only: + return + + if getattr(args, "intel_runtime_policy", False): + machine = os.uname().machine.lower() if hasattr(os, "uname") else "" + require( + sys.platform == "darwin" and machine == "x86_64", + "intel_runtime_host", + archive, + f"Intel runtime policy proof requires macOS x86_64, got {sys.platform}/{machine}", + ) + default_env = proof_agent_environment(proof_env, PROOF_LOCAL_RUN_ID) + for name in ( + "CODESTORY_EMBED_ALLOW_CPU", + "CODESTORY_EMBED_DEVICE_POLICY", + "CODESTORY_EMBED_DEVICE_PROVIDER", + "CODESTORY_EMBED_DEVICE_STATE", + "CODESTORY_EMBED_LLAMACPP_URL", + "CODESTORY_EMBED_SERVER_LAUNCH", + ): + default_env.pop(name, None) + default_env["CODESTORY_EMBED_BACKEND"] = "llamacpp" + default_artifact = out_dir / "intel-default-backend.json" + default_payload = run_command( + cli, + "intel_default_backend", + [ + "retrieval", + "bootstrap", + "--project", + str(project), + "--profile", + "agent", + "--run-id", + PROOF_LOCAL_RUN_ID, + "--skip-compose", + "--wait-secs", + "0", + "--format", + "json", + "--output-file", + str(default_artifact), + ], + default_artifact, + args.timeout_secs, + env=default_env, + ) + require_intel_default_backend_failure(default_payload, default_artifact) + summary["artifacts"]["intel_default_backend"] = str(default_artifact) + + with embedding_probe_server() as endpoint: + cpu_env = { + **default_env, + "CODESTORY_EMBED_ALLOW_CPU": "1", + "CODESTORY_EMBED_LLAMACPP_URL": endpoint, + "CODESTORY_EMBED_SERVER_LAUNCH": "external_endpoint", + } + cpu_artifact = out_dir / "intel-cpu-external.json" + cpu_payload = run_command( + cli, + "intel_cpu_external", + [ + "retrieval", + "bootstrap", + "--project", + str(project), + "--profile", + "agent", + "--run-id", + PROOF_LOCAL_RUN_ID, + "--skip-compose", + "--wait-secs", + "0", + "--format", + "json", + "--output-file", + str(cpu_artifact), + ], + cpu_artifact, + args.timeout_secs, + env=cpu_env, + ) + require_intel_cpu_external_ready(cpu_payload, cpu_artifact, endpoint) + summary["artifacts"]["intel_cpu_external"] = str(cpu_artifact) + write_json(out_dir / "summary.json", summary) + return + + if args.managed_plugin_handoff: + convergence_project = Path(temp) / "managed-convergence-project" + empty_model_dir = Path(temp) / "managed-convergence-empty-model" + write_managed_convergence_fixture(convergence_project) + proof_cleanup_sidecars.extend( + proof_agent_identities([cache_root], convergence_project) + ) + empty_model_dir.mkdir() + summary["managed_convergence_project"] = str(convergence_project) + summary["artifacts"].update( + seed_stale_local_publication( + cli, + convergence_project, + out_dir, + args.timeout_secs, + proof_env, + make_managed_convergence_fixture_stale, + ) + ) + + plugin_artifact = out_dir / "managed-plugin-convergence.json" + plugin_status = plugin_stdio_handoff( + Path(args.plugin_root).resolve(), + archive.parent, + convergence_project, + cache_root, + plugin_artifact, + args.timeout_secs, + args.expected_version, + cli, + empty_model_dir, + archive, + ) + register_current_proof_runtime(cache_root) + summary["artifacts"]["managed_plugin_convergence"] = str(plugin_artifact) + summary["artifacts"]["managed_plugin_upgrade"] = str( + plugin_artifact.with_name("managed-plugin-upgrade.json") + ) + write_json(out_dir / "summary.json", summary) + return + + local_env = proof_agent_environment(proof_env, PROOF_LOCAL_RUN_ID) + ready_artifact = out_dir / "ready.json" + if grounding_convergence: + summary["artifacts"].update( + seed_stale_local_publication( + cli, + project, + out_dir, + args.timeout_secs, + proof_env, + make_repository_convergence_copy_stale, + ) + ) + model_dir = os.environ.get("CODESTORY_EMBED_MODEL_DIR", "").strip() + require( + bool(model_dir) and Path(model_dir).is_dir(), + "managed_plugin_convergence", + archive, + "grounding convergence requires a prepared CODESTORY_EMBED_MODEL_DIR", + ) + plugin_artifact = out_dir / "managed-plugin-ready-convergence.json" + plugin_status = plugin_stdio_handoff( + Path(args.plugin_root).resolve(), + archive.parent, + project, + cache_root, + plugin_artifact, + args.timeout_secs, + args.expected_version, + cli, + Path(model_dir), + archive, + require_ready=True, + question=args.question, + query=args.query, + ) + register_current_proof_runtime(cache_root) + summary["artifacts"]["managed_plugin_ready_convergence"] = str(plugin_artifact) + summary["artifacts"]["managed_plugin_upgrade"] = str( + plugin_artifact.with_name("managed-plugin-upgrade.json") + ) + + restart_artifact = out_dir / "managed-plugin-restart-status.json" + restart_status = plugin_stdio_status( + Path(args.plugin_root).resolve(), + archive.parent, + project, + restart_artifact, + args.timeout_secs, + args.expected_version, + cache_root, + ) + require_plugin_stdio_ready(restart_status, restart_artifact, args.expected_version) + first_launch = ( + plugin_status.get("readiness_broker", {}) + .get("gpu_proof", {}) + .get("runtime_identity", {}) + .get("embedding_launch", {}) + ) + restart_launch = ( + restart_status.get("readiness_broker", {}) + .get("gpu_proof", {}) + .get("runtime_identity", {}) + .get("embedding_launch", {}) + ) + require( + isinstance(first_launch, dict) + and isinstance(restart_launch, dict) + and restart_launch.get("pid") == first_launch.get("pid") + and restart_launch.get("launch_fingerprint_sha256") + == first_launch.get("launch_fingerprint_sha256"), + "managed_plugin_restart", + restart_artifact, + "restarted MCP did not reuse the exact healthy native embedding runtime", + ) + summary["artifacts"]["managed_plugin_restart"] = str(restart_artifact) + ready_args = [ + "ready", + "--goal", + "agent", + "--project", + str(project), + "--format", + "json", + "--output-file", + str(ready_artifact), + ] + else: + ready_args = [ + "ready", + "--goal", + "agent", + "--repair", + "--project", + str(project), + "--format", + "json", + "--output-file", + str(ready_artifact), + ] + ready = run_command( + cli, + "ready", + ready_args, + ready_artifact, + args.timeout_secs, + env=local_env, + ) + require_agent_ready(ready, "ready", ready_artifact) + register_current_proof_runtime(cache_root) + summary["artifacts"]["ready"] = str(ready_artifact) + write_json(out_dir / "summary.json", summary) + + if getattr(args, "native_accelerator_lifecycle", False): + original_launch = require_native_accelerator_ready(ready, "native_ready", ready_artifact) + original_pid = original_launch["pid"] + register_proof_launch(original_launch) + + survival_artifact = out_dir / "native-runtime-survival.json" + survival = run_command( + cli, + "native_runtime_survival", + [ + "ready", + "--goal", + "agent", + "--project", + str(project), + "--format", + "json", + "--output-file", + str(survival_artifact), + ], + survival_artifact, + args.timeout_secs, + env=local_env, ) - require_stdio_shape(stdio_status_payload, stdio_artifact, args.expected_version) - summary["artifacts"]["serve_stdio"] = str(stdio_artifact) + survival_launch = require_native_accelerator_ready( + survival, + "native_runtime_survival", + survival_artifact, + expected_pid=original_pid, + ) + register_proof_launch(survival_launch) + summary["artifacts"]["native_runtime_survival"] = str(survival_artifact) + cold_warm_evidence = preserve_native_embedding_evidence( + cache_root, + out_dir, + "native-cold-warm", + required=True, + exact_launch=survival_launch, + ) + summary["artifacts"]["native_cold_warm_launch"] = str(cold_warm_evidence) write_json(out_dir / "summary.json", summary) - if args.version_only: - return + os.kill(original_pid, signal.SIGTERM) + try: + wait_for_process_exit(original_pid) + except TimeoutError as exc: + fail("native_runtime_shutdown", survival_artifact, str(exc)) - if args.managed_plugin_handoff: - local_ready_artifact = out_dir / "managed-local-ready.json" - run_command( + blocked_artifact = out_dir / "native-runtime-dead-status.json" + blocked = run_command( cli, - "managed_local_ready", + "native_runtime_dead_status", [ "ready", "--goal", - "local", - "--repair", + "agent", "--project", str(project), "--format", "json", "--output-file", - str(local_ready_artifact), + str(blocked_artifact), ], - local_ready_artifact, + blocked_artifact, args.timeout_secs, - env=proof_env, + env=local_env, ) - summary["artifacts"]["managed_local_ready"] = str(local_ready_artifact) + require_agent_not_ready(blocked, "native_runtime_dead_status", blocked_artifact) + summary["artifacts"]["native_runtime_dead_status"] = str(blocked_artifact) - local_ground_artifact = out_dir / "managed-local-ground.json" - ground = run_command( + recovery_artifact = out_dir / "native-runtime-recovery.json" + recovery = run_command( cli, - "managed_local_ground", + "native_runtime_recovery", [ - "ground", + "ready", + "--goal", + "agent", + "--repair", "--project", str(project), - "--refresh", - "none", "--format", "json", "--output-file", - str(local_ground_artifact), + str(recovery_artifact), ], - local_ground_artifact, + recovery_artifact, args.timeout_secs, - env=proof_env, + env=local_env, + ) + recovered_launch = require_native_accelerator_ready( + recovery, + "native_runtime_recovery", + recovery_artifact, ) + recovered_pid = recovered_launch["pid"] + register_proof_launch(recovered_launch) require( - isinstance(ground, dict) and isinstance(ground.get("stats"), dict), - "managed_local_ground", - local_ground_artifact, - "managed local ground output is missing repository stats", + recovered_pid != original_pid, + "native_runtime_recovery", + recovery_artifact, + "repair reused the terminated native embedding pid", ) - summary["artifacts"]["managed_local_ground"] = str(local_ground_artifact) - - plugin_artifact = out_dir / "managed-plugin-handoff.json" - plugin_status = plugin_stdio_handoff( - Path(args.plugin_root).resolve(), - archive.parent, - project, + summary["artifacts"]["native_runtime_recovery"] = str(recovery_artifact) + recovery_evidence = preserve_native_embedding_evidence( cache_root, - plugin_artifact, - args.timeout_secs, - args.expected_version, - cli, + out_dir, + "native-recovery", + required=True, + exact_launch=recovered_launch, ) - summary["artifacts"]["managed_plugin_handoff"] = str(plugin_artifact) + summary["artifacts"]["native_recovery_launch"] = str(recovery_evidence) write_json(out_dir / "summary.json", summary) - return - - local_bootstrap_artifact = out_dir / "local-retrieval-bootstrap.json" - run_command( - cli, - "local_retrieval_bootstrap", - [ - "retrieval", - "bootstrap", - "--project", - str(project), - "--profile", - "local", - "--format", - "json", - "--output-file", - str(local_bootstrap_artifact), - ], - local_bootstrap_artifact, - args.timeout_secs, - env=proof_env, - ) - summary["artifacts"]["local_retrieval_bootstrap"] = str(local_bootstrap_artifact) - write_json(out_dir / "summary.json", summary) - - local_index_artifact = out_dir / "local-retrieval-index.json" - local_index = run_command( - cli, - "local_retrieval_index", - [ - "retrieval", - "index", - "--project", - str(project), - "--profile", - "local", - "--refresh", - "full", - "--format", - "json", - "--output-file", - str(local_index_artifact), - ], - local_index_artifact, - args.timeout_secs, - env=proof_env, - ) - require_retrieval_index_ready(local_index, "local_retrieval_index", local_index_artifact) - summary["artifacts"]["local_retrieval_index"] = str(local_index_artifact) - write_json(out_dir / "summary.json", summary) - - local_status_artifact = out_dir / "local-retrieval-status.json" - local_status = run_command( - cli, - "local_retrieval_status", - [ - "retrieval", - "status", - "--project", - str(project), - "--profile", - "local", - "--format", - "json", - "--output-file", - str(local_status_artifact), - ], - local_status_artifact, - args.timeout_secs, - env=proof_env, - ) - require_retrieval_full(local_status, "local_retrieval_status", local_status_artifact) - summary["artifacts"]["local_retrieval_status"] = str(local_status_artifact) - write_json(out_dir / "summary.json", summary) - - ready_artifact = out_dir / "ready.json" - ready = run_command( - cli, - "ready", - [ - "ready", - "--goal", - "agent", - "--repair", - "--project", - str(project), - "--format", - "json", - "--output-file", - str(ready_artifact), - ], - ready_artifact, - args.timeout_secs, - env=proof_env, - ) - require_agent_ready(ready, "ready", ready_artifact) - summary["artifacts"]["ready"] = str(ready_artifact) - write_json(out_dir / "summary.json", summary) - - doctor_artifact = out_dir / "doctor.json" - doctor = run_command( - cli, - "doctor", - ["doctor", "--project", str(project), "--format", "json", "--output-file", str(doctor_artifact)], - doctor_artifact, - args.timeout_secs, - env=proof_env, - ) - require_retrieval_full(doctor, "doctor", doctor_artifact) - summary["artifacts"]["doctor"] = str(doctor_artifact) - write_json(out_dir / "summary.json", summary) status_artifact = out_dir / "retrieval-status.json" status = run_command( @@ -1215,7 +3878,7 @@ def run_gate(args: argparse.Namespace) -> None: ], status_artifact, args.timeout_secs, - env=proof_env, + env=local_env, ) require_retrieval_full(status, "retrieval_status", status_artifact) summary["artifacts"]["retrieval_status"] = str(status_artifact) @@ -1239,35 +3902,12 @@ def run_gate(args: argparse.Namespace) -> None: ], search_artifact, args.timeout_secs, - env=proof_env, + env=local_env, ) require_search_full(search, search_artifact) summary["artifacts"]["search"] = str(search_artifact) write_json(out_dir / "summary.json", summary) - context_artifact = out_dir / "context.json" - context = run_command( - cli, - "context", - [ - "context", - "--project", - str(project), - "--query", - args.context_query, - "--format", - "json", - "--output-file", - str(context_artifact), - ], - context_artifact, - args.timeout_secs, - env=proof_env, - ) - require_context_ready(context, context_artifact) - summary["artifacts"]["context"] = str(context_artifact) - write_json(out_dir / "summary.json", summary) - packet_artifact = out_dir / "packet.json" packet = run_command( cli, @@ -1287,26 +3927,15 @@ def run_gate(args: argparse.Namespace) -> None: ], packet_artifact, args.timeout_secs, - env=proof_env, + env=local_env, ) require_packet_ready(packet, packet_artifact) summary["artifacts"]["packet"] = str(packet_artifact) write_json(out_dir / "summary.json", summary) - stdio_attempts = [] - try: - stdio_status_payload = stdio_status(cli, project, stdio_artifact, args.timeout_secs, proof_env) - stdio_attempts.append(stdio_status_payload) - require_stdio_shape(stdio_status_payload, stdio_artifact, args.expected_version) - allowed = stdio_status_payload.get("allowed_surfaces", {}) - if not all(allowed.get(name, {}).get("allowed") is True for name in ("packet", "search", "context")): - shutil.copy2(stdio_artifact, out_dir / "serve-stdio-status-initial.json") - stdio_status_payload = stdio_status(cli, project, stdio_artifact, args.timeout_secs, proof_env) - stdio_attempts.append(stdio_status_payload) - require_stdio_ready(stdio_status_payload, stdio_artifact, args.expected_version) - finally: - for status_attempt in stdio_attempts: - terminate_status_workers(status_attempt) + stdio_status_payload = stdio_status(cli, project, stdio_artifact, args.timeout_secs, local_env) + require_stdio_shape(stdio_status_payload, stdio_artifact, args.expected_version) + require_stdio_ready(stdio_status_payload, stdio_artifact, args.expected_version) summary["artifacts"]["serve_stdio"] = str(stdio_artifact) write_json(out_dir / "summary.json", summary) @@ -1328,616 +3957,297 @@ def run_gate(args: argparse.Namespace) -> None: print(f"packaged agent proof passed; artifacts={out_dir}") -def write_fake_cli(path: Path) -> None: - fake = path / "fake_cli.py" - fake.write_text( - textwrap.dedent( - r''' - import hashlib - import json - import os - import sys - import time - - def emit(value): - if "--output-file" in sys.argv: - out = sys.argv[sys.argv.index("--output-file") + 1] - open(out, "w", encoding="utf-8").write(json.dumps(value)) - else: - print(json.dumps(value)) - - fail = os.environ.get("CODESTORY_FAKE_FAIL_LAYER") - if "--version" in sys.argv: - print("codestory-cli 9.9.9") - raise SystemExit(0) - if "--help" in sys.argv: - print("Usage: codestory-cli [OPTIONS] ") - raise SystemExit(0) - layer = sys.argv[1] - if layer == "retrieval" and len(sys.argv) > 2: - layer = "retrieval_" + sys.argv[2].replace("-", "_") - if fail == f"{layer}_stderr": - print("forced stderr failure", file=sys.stderr) - raise SystemExit(3) - if fail == layer: - print("forced failure") - raise SystemExit(2) - if layer == "ready": - emit({"verdicts": [{ - "goal": "agent_packet_search", - "status": "ready", - "summary": "ready", - "minimum_next": [], - "full_repair": [], - }]}) - elif layer == "doctor": - emit({"retrieval_mode": "full"}) - elif layer == "retrieval_bootstrap": - emit({ - "cache_root": os.environ.get("CODESTORY_CACHE_ROOT"), - "project_status": {"retrieval_mode": "unavailable"}, - }) - elif layer == "retrieval_index": - emit({"zoekt_stubbed": False, "qdrant_stubbed": False, "scip_stubbed": False}) - elif layer == "retrieval_status": - emit({"retrieval_mode": "full"}) - elif layer == "search": - emit({"retrieval_shadow": {"retrieval_mode": "full"}, "indexed_symbol_hits": [{"node_id": "1"}]}) - elif layer == "context": - if fail == "context_weak": - emit({"retrieval_trace": {"resolved_profile": "investigate"}}) - elif fail == "context_fallback": - emit({ - "context": { - "retrieval_version": "sidecar", - "retrieval_trace": { - "steps": [{}], - "retrieval_shadow": {"retrieval_mode": "fallback"}, - }, - }, - }) - else: - emit({ - "context": { - "retrieval_version": "sidecar", - "retrieval_trace": { - "resolved_profile": "investigate", - "steps": [{}], - "retrieval_shadow": {"retrieval_mode": "full"}, - }, - }, - }) - elif layer == "packet": - if fail == "packet_weak": - emit({"sufficiency": {"status": "supported"}, "answer": {"retrieval_version": "fallback"}, "retrieval_trace_summary": {}}) - else: - emit({"sufficiency": {"status": "sufficient"}, "answer": {"retrieval_version": "sidecar"}, "retrieval_trace_summary": {}}) - elif layer == "ground": - emit({ - "cache_root": os.environ.get("CODESTORY_CACHE_ROOT"), - "root": os.getcwd(), - "stats": {"file_count": 1, "node_count": 1}, - }) - elif layer == "serve": - marker = None - repair_attempt = None - if fail in {"serve_first_blocked", "serve_first_blocked_then_timeout"}: - try: - project = sys.argv[sys.argv.index("--project") + 1] - marker = os.path.join(project, ".fake-serve-first-blocked-seen") - except (ValueError, IndexError): - marker = os.path.join(os.getcwd(), ".fake-serve-first-blocked-seen") - for line in sys.stdin: - request = json.loads(line) - if fail == "serve_timeout": - time.sleep(60) - continue - if request.get("method") == "tools/list": - result = {"tools": [{"name": "packet"}, {"name": "search"}, {"name": "context"}, {"name": "sidecar_setup"}]} - elif request.get("method") == "resources/list": - resources = [{"uri": "codestory://status", "name": "CodeStory runtime status"}] - if fail != "resources_hidden": - resources.append({"uri": "codestory://agent-guide", "name": "CodeStory agent guide"}) - result = {"resources": resources} - elif request.get("method") == "tools/call" and request.get("params", {}).get("name") == "sidecar_setup": - repair = { - "status": "started", - "mode": "background", - "pid": os.getpid(), - "attempt_id": "fake-attempt", - "reservation_published": True, - "recommended_next_calls": [{ - "method": "tools/call", - "tool": "status", - "arguments": {"project": os.getcwd()}, - }], - } - repair_attempt = repair["attempt_id"] - result = {"content": [{"type": "text", "text": json.dumps(repair)}], "structuredContent": repair} - else: - if fail == "serve_first_blocked_then_timeout" and marker is not None and os.path.exists(marker): - time.sleep(60) - continue - serve_allowed = True - refresh_worker_pid = None - if marker is not None and not os.path.exists(marker): - refresh_worker_pid = int(os.environ["CODESTORY_FAKE_STATUS_WORKER_PID"]) - open(marker, "w", encoding="utf-8").write(str(refresh_worker_pid)) - serve_allowed = False - elif marker is not None: - refresh_worker_pid = int(open(marker, encoding="utf-8").read()) - try: - os.kill(refresh_worker_pid, 0) - except OSError: - serve_allowed = False - server_executable = os.environ.get("CODESTORY_FAKE_SERVER_EXECUTABLE", sys.argv[0]) - server_sha256 = hashlib.sha256(open(server_executable, "rb").read()).hexdigest() - status = { - "cache_root": os.environ.get("CODESTORY_CACHE_ROOT"), - "readiness_broker": { - "operations": ([{ - "operation_kind": "local_graph_refresh", - "pid": refresh_worker_pid, - "status": "running", - }] if refresh_worker_pid else []), - }, - "server_version": "9.9.9", - "cli_version": "9.9.9", - "server_executable": server_executable, - "server_executable_sha256": server_sha256, - "sidecar_contract_version": 1, - "plugin_runtime": { - "cli_source": "managed" if os.environ.get("CODESTORY_FAKE_PLUGIN_MANAGED") == "1" else "direct_cli_launch", - "plugin_version": "9.9.9", - "build_source": "github_release", - "repo_ref": "v9.9.9", - "cli_version": "9.9.9", - "plugin_root": os.getcwd(), - "managed_binary_path": server_executable, - }, - "sidecar_setup": { - "active_repair": {"attempt_id": repair_attempt} if repair_attempt else None, - "last_worker_result": None, - }, - "allowed_surfaces": { - "ground": {"allowed": True}, - "packet": {"allowed": serve_allowed}, - "search": {"allowed": serve_allowed}, - "context": {"allowed": serve_allowed}, - }, - } - result = {"contents": [{"uri": "codestory://status", "mimeType": "application/json", "text": json.dumps(status)}]} - print(json.dumps({"jsonrpc": "2.0", "id": request.get("id"), "result": result}), flush=True) - else: - raise SystemExit(f"unknown fake layer: {layer}") - ''' - ).lstrip(), - encoding="utf-8", - ) - if os.name == "nt": - wrapper = path / "codestory-cli.cmd" - wrapper.write_text(f'@echo off\r\n"{sys.executable}" "%~dp0fake_cli.py" %*\r\n', encoding="utf-8") - else: - wrapper = path / "codestory-cli" - wrapper.write_text(f"#!{sys.executable}\nimport runpy\nrunpy.run_path({str(fake)!r}, run_name='__main__')\n", encoding="utf-8") - wrapper.chmod(wrapper.stat().st_mode | stat.S_IXUSR) - - -def write_fake_plugin_launcher(plugin_root: Path) -> None: - launcher = plugin_root / "scripts" / "codestory-mcp.cjs" - launcher.parent.mkdir(parents=True) - launcher.write_text( - textwrap.dedent( - r''' - const { spawn } = require('child_process'); - const fs = require('fs'); - const path = require('path'); - - if (process.argv[2] === 'sidecar-policy') { - if (process.env.CODESTORY_FAKE_PLUGIN_POLICY_TIMEOUT === '1') { - process.stdout.write('policy stdout before timeout\n'); - process.stderr.write('policy stderr before timeout\n'); - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 60_000); - } - const policyIndex = process.argv.indexOf('--policy-file'); - if (policyIndex >= 0 && process.argv[policyIndex + 1]) { - fs.writeFileSync(process.argv[policyIndex + 1], JSON.stringify({ state: 'enabled' })); - } - process.exit(0); - } - - let cli = process.env.CODESTORY_FAKE_PLUGIN_CLI; - if (!cli) { - process.stderr.write('CODESTORY_FAKE_PLUGIN_CLI is required\n'); - process.exit(2); - } - if (process.env.CODESTORY_FAKE_PLUGIN_MANAGED === '1') { - const managedDir = path.join(process.env.PLUGIN_DATA, 'codestory-cli', '9.9.9', 'bin'); - fs.mkdirSync(managedDir, { recursive: true }); - const sourceDir = path.dirname(cli); - const managedCli = path.join(managedDir, path.basename(cli)); - fs.copyFileSync(cli, managedCli); - fs.copyFileSync(path.join(sourceDir, 'fake_cli.py'), path.join(managedDir, 'fake_cli.py')); - cli = managedCli; - } - - const child = spawn( - cli, - ['serve', '--stdio', '--refresh', 'none', '--project', process.cwd()], - { - stdio: 'inherit', - shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(cli), - env: { - ...process.env, - CODESTORY_FAKE_FAIL_LAYER: process.env.CODESTORY_FAKE_PLUGIN_HIDE_RESOURCES === '1' - ? 'resources_hidden' - : process.env.CODESTORY_FAKE_FAIL_LAYER || '', - CODESTORY_FAKE_SERVER_EXECUTABLE: cli, - }, - }, - ); - child.on('exit', (code, signal) => { - if (signal) process.kill(process.pid, signal); - process.exit(code || 0); - }); - child.on('error', (error) => { - process.stderr.write(`${error.message}\n`); - process.exit(1); - }); - ''' - ).lstrip(), - encoding="utf-8", - ) - - def self_test() -> None: with tempfile.TemporaryDirectory(prefix="codestory-packaged-proof-self-test-") as temp: root = Path(temp) - stage = root / "pkg" / "codestory-cli-v9.9.9-test" - stage.mkdir(parents=True) - write_fake_cli(stage) - skill = stage / PLUGIN_SKILL_RELATIVE - skill.parent.mkdir(parents=True) - skill.write_text("name: codestory-grounding\n", encoding="utf-8") - archive = root / "codestory-cli-v9.9.9-test.zip" - with zipfile.ZipFile(archive, "w") as handle: - for path in stage.rglob("*"): - handle.write(path, path.relative_to(stage.parent).as_posix()) - checksum_file = root / "SHA256SUMS.txt" - checksum_file.write_text(f"{sha256_file(archive)} {archive.name}\n", encoding="utf-8") - project = root / "repo" - project.mkdir() - out_dir = root / "out" - args = argparse.Namespace( - archive=str(archive), - project=str(project), - out_dir=str(out_dir), - query=DEFAULT_QUERY, - context_query=DEFAULT_QUERY, - question=DEFAULT_QUESTION, - expected_version="9.9.9", - checksum_file=str(checksum_file), - plugin_root=None, - timeout_secs=30, - version_only=False, - managed_plugin_handoff=False, - ) - run_gate(args) - assert (out_dir / "summary.json").is_file() - stdio_artifact = read_json_file(out_dir / "serve-stdio-status.json") - full_cache_root = read_json_file(out_dir / "local-retrieval-bootstrap.json")["cache_root"] - assert Path(full_cache_root).name.startswith("codestory-packaged-proof-cache-") - assert stdio_artifact["status"]["cache_root"] == full_cache_root - status_request = next( - entry["request"] - for entry in stdio_artifact["transcript"] - if entry["request"].get("id") == "status" - ) - assert status_request["params"]["project"] == str(project.resolve()) - - version_only_out = root / "version-only-out" - version_only_args = argparse.Namespace(**vars(args)) - version_only_args.out_dir = str(version_only_out) - version_only_args.version_only = True - run_gate(version_only_args) - version_only_summary = read_json_file(version_only_out / "summary.json") - assert version_only_summary["artifacts"].keys() == {"checksum", "version", "help", "serve_stdio"} - assert not (version_only_out / "local-retrieval-bootstrap.json").exists() - version_only_status = read_json_file(version_only_out / "serve-stdio-status.json")["status"] - assert Path(version_only_status["cache_root"]).name.startswith("codestory-packaged-stdio-cache-") - assert version_only_status["cache_root"] != full_cache_root - - bad_checksum = root / "BAD_SHA256SUMS.txt" - bad_checksum.write_text(f"{'0' * 64} {archive.name}\n", encoding="utf-8") - bad_checksum_args = argparse.Namespace(**vars(version_only_args)) - bad_checksum_args.out_dir = str(root / "bad-checksum-out") - bad_checksum_args.checksum_file = str(bad_checksum) - try: - run_gate(bad_checksum_args) - except GateFailure as exc: - assert exc.layer == "checksum" - assert exc.artifact.name == "archive-checksum.json" - else: - raise AssertionError("mismatched packaged checksum should fail the gate") - - stale_out = root / "stale-out" - stale_out.mkdir() - stale_file = stale_out / "stale-plugin-stdio-status.json" - stale_file.write_text("stale", encoding="utf-8") - stale_args = argparse.Namespace(**vars(version_only_args)) - stale_args.out_dir = str(stale_out) - run_gate(stale_args) - assert not stale_file.exists() - assert (stale_out / "summary.json").is_file() - - delayed_stdio_project = root / "repo-delayed-stdio" - delayed_stdio_project.mkdir() - delayed_stdio_out = root / "delayed-stdio-out" - delayed_stdio_args = argparse.Namespace(**vars(args)) - delayed_stdio_args.project = str(delayed_stdio_project) - delayed_stdio_args.out_dir = str(delayed_stdio_out) - delayed_status_worker = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(60)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - creationflags=( - subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS - if os.name == "nt" - else 0 - ), - start_new_session=os.name != "nt", - ) - os.environ["CODESTORY_FAKE_FAIL_LAYER"] = "serve_first_blocked" - os.environ["CODESTORY_FAKE_STATUS_WORKER_PID"] = str(delayed_status_worker.pid) - try: - run_gate(delayed_stdio_args) - delayed_stdio_status = read_json_file(delayed_stdio_out / "serve-stdio-status.json") - delayed_status = delayed_stdio_status["status"] - assert delayed_status["allowed_surfaces"]["packet"]["allowed"] is True - assert (delayed_stdio_project / ".fake-serve-first-blocked-seen").is_file() - delayed_status_worker.wait(timeout=5) - assert delayed_status_worker.returncode is not None - finally: - terminate_worker_pid(delayed_status_worker.pid) - os.environ.pop("CODESTORY_FAKE_FAIL_LAYER", None) - os.environ.pop("CODESTORY_FAKE_STATUS_WORKER_PID", None) - - retry_timeout_project = root / "repo-retry-timeout" - retry_timeout_project.mkdir() - retry_timeout_args = argparse.Namespace(**vars(args)) - retry_timeout_args.project = str(retry_timeout_project) - retry_timeout_args.out_dir = str(root / "retry-timeout-out") - retry_timeout_args.timeout_secs = 5 - retry_timeout_worker = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(60)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - creationflags=( - subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS - if os.name == "nt" - else 0 - ), - start_new_session=os.name != "nt", - ) - os.environ["CODESTORY_FAKE_FAIL_LAYER"] = "serve_first_blocked_then_timeout" - os.environ["CODESTORY_FAKE_STATUS_WORKER_PID"] = str(retry_timeout_worker.pid) - try: - try: - run_gate(retry_timeout_args) - except GateFailure as exc: - assert exc.layer == "serve_stdio" - assert "serve-stdio-status.json" in str(exc.artifact) - else: - raise AssertionError("timed-out stdio readiness retry should fail the gate") - retry_timeout_worker.wait(timeout=5) - assert retry_timeout_worker.returncode is not None - finally: - terminate_worker_pid(retry_timeout_worker.pid) - os.environ.pop("CODESTORY_FAKE_FAIL_LAYER", None) - os.environ.pop("CODESTORY_FAKE_STATUS_WORKER_PID", None) - - os.environ["CODESTORY_FAKE_FAIL_LAYER"] = "search" - try: - try: - run_gate(args) - except GateFailure as exc: - assert exc.layer == "search" - assert "search.json.stdout.txt" in str(exc.artifact) - else: - raise AssertionError("forced fake search failure should fail the gate") - finally: - os.environ.pop("CODESTORY_FAKE_FAIL_LAYER", None) - - mismatch_args = argparse.Namespace(**vars(args)) - mismatch_args.expected_version = "9.9.8" - try: - run_gate(mismatch_args) - except GateFailure as exc: - assert exc.layer == "version" - assert "version.txt" in str(exc.artifact) - else: - raise AssertionError("version mismatch should fail the gate") + artifact = root / "validator.json" + assert fnv1a_bytes(r"C:\CodeStory".encode("utf-16le")) == "bf15b1ad4ccb5afe" + runner_temp = os.environ.get("RUNNER_TEMP", "").strip() + proof_root_parent = Path(runner_temp).resolve(strict=True) if runner_temp else root - plugin_root = root / "plugin" - plugin_manifest = plugin_root / ".codex-plugin" / "plugin.json" - plugin_manifest.parent.mkdir(parents=True) - plugin_manifest.write_text(json.dumps({"version": "9.9.8"}), encoding="utf-8") - try: - require_plugin_manifest_version(plugin_root, "9.9.9") - except GateFailure as exc: - assert exc.layer == "plugin_manifest" - assert exc.artifact == plugin_manifest - else: - raise AssertionError("plugin manifest version mismatch should fail the gate") + def fake_owned_delete(boundary: Path, relative: Path) -> None: + remove_tree_with_retry(boundary / relative) - plugin_drift_args = argparse.Namespace(**vars(args)) - plugin_drift_args.plugin_root = str(plugin_root) - try: - run_gate(plugin_drift_args) - except GateFailure as exc: - assert exc.layer == "plugin_manifest" - assert exc.artifact == plugin_manifest - else: - raise AssertionError("plugin-root drift should fail the packaged proof gate") - - plugin_manifest.write_text(json.dumps({"version": "9.9.9"}), encoding="utf-8") - write_fake_plugin_launcher(plugin_root) - plugin_hidden_args = argparse.Namespace(**vars(args)) - plugin_hidden_args.plugin_root = str(plugin_root) - plugin_hidden_args.out_dir = str(root / "plugin-hidden-out") - os.environ["CODESTORY_FAKE_PLUGIN_HIDE_RESOURCES"] = "1" - os.environ["CODESTORY_FAKE_PLUGIN_CLI"] = str(stage / ("codestory-cli.cmd" if os.name == "nt" else "codestory-cli")) - try: - try: - run_gate(plugin_hidden_args) - except GateFailure as exc: - assert exc.layer == "plugin_stdio" - assert "plugin-stdio-status.json" in str(exc.artifact) - artifact = read_json_file(exc.artifact) - assert artifact["server_advertised_mcp_resources"]["missing"] == ["codestory://agent-guide"] - assert artifact["status"]["plugin_runtime"]["cli_source"] == "direct_cli_launch" - hidden_cache_root = read_json_file( - Path(plugin_hidden_args.out_dir) / "local-retrieval-bootstrap.json" - )["cache_root"] - assert artifact["status"]["cache_root"] == hidden_cache_root - else: - raise AssertionError("hidden plugin MCP resources should fail the plugin stdio gate") - finally: - os.environ.pop("CODESTORY_FAKE_PLUGIN_HIDE_RESOURCES", None) - os.environ.pop("CODESTORY_FAKE_PLUGIN_CLI", None) - - managed_args = argparse.Namespace(**vars(args)) - managed_args.plugin_root = str(plugin_root) - managed_args.out_dir = str(root / "managed-plugin-out") - managed_args.managed_plugin_handoff = True - os.environ["CODESTORY_FAKE_PLUGIN_CLI"] = str( - stage / ("codestory-cli.cmd" if os.name == "nt" else "codestory-cli") - ) - os.environ["CODESTORY_FAKE_PLUGIN_MANAGED"] = "1" - try: - run_gate(managed_args) - managed_summary = read_json_file(Path(managed_args.out_dir) / "summary.json") - assert "managed_local_ground" in managed_summary["artifacts"] - assert "managed_plugin_handoff" in managed_summary["artifacts"] - managed_artifact = Path(managed_args.out_dir) / "managed-plugin-handoff.json" - assert transcript_response(managed_artifact, "repair")["result"]["structuredContent"]["status"] == "started" - managed_status_after = status_from_resource_response( - transcript_response(managed_artifact, "status_after_repair") - ) - assert managed_status_after["sidecar_setup"]["active_repair"]["attempt_id"] == "fake-attempt" - managed_cache_root = read_json_file(Path(managed_args.out_dir) / "managed-local-ground.json")["cache_root"] - assert Path(managed_cache_root).name.startswith("codestory-packaged-proof-cache-") - assert managed_status_after["cache_root"] == managed_cache_root - managed_direct_status = read_json_file(Path(managed_args.out_dir) / "serve-stdio-status.json")["status"] - assert Path(managed_direct_status["cache_root"]).name.startswith("codestory-packaged-stdio-cache-") - assert managed_direct_status["cache_root"] != managed_cache_root - finally: - os.environ.pop("CODESTORY_FAKE_PLUGIN_MANAGED", None) - os.environ.pop("CODESTORY_FAKE_PLUGIN_CLI", None) + assert docker_created_epoch_ms( + "2026-07-13T14:08:36.245344828Z" + ) == docker_created_epoch_ms("2026-07-13T14:08:36.245344Z") - policy_timeout_artifact = root / "policy-timeout" / "managed-plugin-handoff.json" - policy_timeout_artifact.parent.mkdir(parents=True) - os.environ["CODESTORY_FAKE_PLUGIN_CLI"] = str( - stage / ("codestory-cli.cmd" if os.name == "nt" else "codestory-cli") + require_agent_not_ready( + { + "verdicts": [{ + "goal": "agent_packet_search", + "status": "repair_retrieval", + }], + "readiness_broker": { + "gpu_proof": {"proof_status": "gpu_unverified"} + }, + }, + "native_runtime_dead_status", + artifact, ) - os.environ["CODESTORY_FAKE_PLUGIN_POLICY_TIMEOUT"] = "1" - try: - try: - plugin_stdio_handoff( - plugin_root, - archive.parent, - project, - root / "policy-timeout-cache", - policy_timeout_artifact, - 2, - "9.9.9", - stage / ("codestory-cli.cmd" if os.name == "nt" else "codestory-cli"), - ) - except GateFailure as exc: - assert exc.layer == "managed_plugin_handoff" - assert exc.artifact.name == "managed-plugin-policy.json" - policy_timeout = read_json_file(exc.artifact) - assert "policy stdout before timeout" in policy_timeout["stdout"] - assert "policy stderr before timeout" in policy_timeout["stderr"] - else: - raise AssertionError("timed-out plugin policy enable should fail the gate") - finally: - os.environ.pop("CODESTORY_FAKE_PLUGIN_POLICY_TIMEOUT", None) - os.environ.pop("CODESTORY_FAKE_PLUGIN_CLI", None) - - os.environ["CODESTORY_FAKE_FAIL_LAYER"] = "doctor_stderr" - try: - try: - run_gate(args) - except GateFailure as exc: - assert exc.layer == "doctor" - assert "doctor.json.stderr.txt" in str(exc.artifact) - else: - raise AssertionError("stderr fake failure should point at stderr artifact") - finally: - os.environ.pop("CODESTORY_FAKE_FAIL_LAYER", None) - os.environ["CODESTORY_FAKE_FAIL_LAYER"] = "packet_weak" - try: - try: - run_gate(args) - except GateFailure as exc: - assert exc.layer == "packet" - assert "packet.json" in str(exc.artifact) - else: - raise AssertionError("weak fake packet should fail the gate") - finally: - os.environ.pop("CODESTORY_FAKE_FAIL_LAYER", None) + with embedding_probe_server() as endpoint: + port = int(endpoint.split(":", 2)[2].split("/", 1)[0]) + assert port_reachability(port) + require_intel_cpu_external_ready( + { + "compose_started": False, + "embed_reachable": True, + "sidecar_state": { + "embed_url": endpoint, + "embedding_device_policy": "cpu_allowed", + "embedding_device_state": "cpu", + "embedding_device_observation_source": "cpu_policy", + "embedding_cpu_allowed": True, + "embedding_accelerator_requested": False, + "embedding_accelerator_request_provider": None, + }, + }, + artifact, + endpoint, + ) - os.environ["CODESTORY_FAKE_FAIL_LAYER"] = "context_weak" - try: + if sys.platform == "darwin": + probe_executable = "/bin/sleep" + probe_arguments = ["60"] + process_probe = subprocess.Popen( + [probe_executable, *probe_arguments], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) try: - run_gate(args) - except GateFailure as exc: - assert exc.layer == "context" - assert "context.json" in str(exc.artifact) - else: - raise AssertionError("weak fake context should fail the gate") - finally: - os.environ.pop("CODESTORY_FAKE_FAIL_LAYER", None) - - os.environ["CODESTORY_FAKE_FAIL_LAYER"] = "context_fallback" - try: + identity_deadline = time.monotonic() + 2 + while True: + observed_process = darwin_process_argv(process_probe.pid) + if observed_process is not None: + break + if process_probe.poll() is not None or time.monotonic() >= identity_deadline: + raise AssertionError("Darwin process identity did not stabilize") + time.sleep(0.05) + launch = { + "pid": process_probe.pid, + "spawned_at_epoch_ms": int(time.time() * 1000), + "executable_path": observed_process[0], + "launch_args": probe_arguments, + } + assert registered_native_process_snapshot(launch)["status"] == "matching" + wrong_argv = { + **launch, + "launch_args": ["6"], + } + assert ( + registered_native_process_snapshot(wrong_argv)["status"] + == "identity_mismatch" + ) + finally: + process_probe.terminate() + process_probe.wait(timeout=5) + + compose_file = root / "docker" / "retrieval-compose.yml" + compose_file.parent.mkdir() + compose_file.write_text("services: {}\n", encoding="utf-8") + + def write_proof_compose_state( + cache_root: Path, + overrides: dict | None = None, + *, + state_project: Path = root, + ) -> dict[str, str]: + cache_root.mkdir() + identity = proof_agent_identity( + cache_root, state_project, PROOF_LOCAL_RUN_ID + ) + state_root = Path(identity["state_file"]).parent + for name in ("qdrant", "lexical", "scip"): + (state_root / name).mkdir(parents=True, exist_ok=True) + state = { + "owner": "codestory", + "profile": "agent", + "run_id": PROOF_LOCAL_RUN_ID, + "namespace": identity["namespace"], + "compose_project": identity["compose_project"], + "compose_file": str(compose_file), + "qdrant_data_dir": identity["qdrant_data_dir"], + "lexical_data_dir": identity["lexical_data_dir"], + "scip_artifacts_root": identity["scip_artifacts_root"], + } + state.update(overrides or {}) + write_json(Path(identity["state_file"]), state) + return identity + + if os.name != "nt": + worker_cache = root / "ready-repair-cleanup" + identity = proof_agent_identity(worker_cache, root, PROOF_LOCAL_RUN_ID) + Path(identity["state_file"]).parent.mkdir(parents=True) + worker = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) try: - run_gate(args) - except GateFailure as exc: - assert exc.layer == "context" - assert "context.json" in str(exc.artifact) + status, start = process_start_identity_snapshot(worker.pid) + assert status == "running" and start + try: + terminate_worker_pid( + worker.pid, + expected_start_identity=f"{start}-reused", + ) + except RuntimeError as exc: + assert "reused worker pid" in str(exc) + else: + raise AssertionError("PID reuse must fail closed") + write_json( + Path(identity["state_file"]).with_name( + "ready-repair-enqueue.lock" + ), + { + "project_root": identity["project"], + "profile": identity["profile"], + "run_id": identity["run_id"], + "namespace": identity["namespace"], + "pid": worker.pid, + "token": "proof-ground-activation", + "process_start_identity": start, + "adopted": True, + }, + ) + cleanup, errors = cleanup_proof_owned_repair_workers( + worker_cache, root, [identity] + ) + worker.wait(timeout=5) + assert not errors and cleanup[0]["status"] == "terminated" + finally: + if worker.poll() is None: + worker.terminate() + worker.wait(timeout=5) + + compose_cache = root / "compose-cleanup" + identity = write_proof_compose_state(compose_cache) + assert identity["namespace"].startswith("codestory-agent-v3-") + assert Path(identity["state_file"]).name == SIDECAR_STATE_FILE_V3 + compose_calls = [] + + def fake_docker(command, **kwargs): + compose_calls.append(command) + namespace = kwargs["env"]["CODESTORY_SIDECAR_NAMESPACE"] + qdrant_root = kwargs["env"]["CODESTORY_QDRANT_DATA_DIR"] + container_id = f"container-{fnv1a_hex(qdrant_root)}" + network_id = f"network-{fnv1a_hex(qdrant_root)}" + if command[1:3] == ["container", "ls"]: + stdout = json.dumps({"ID": container_id}) + elif command[1:3] == ["network", "ls"]: + stdout = json.dumps({"ID": network_id}) + elif command[1:3] == ["container", "inspect"]: + stdout = json.dumps([{ + "Id": container_id, + "Name": f"/{namespace}-qdrant", + "Created": "2026-07-12T12:00:00Z", + "Config": {"Labels": { + "com.docker.compose.project": namespace, + "com.docker.compose.service": "qdrant", + "dev.codestory.owner": "codestory", + "dev.codestory.profile": "agent", + "dev.codestory.namespace": namespace, + }}, + "Mounts": [{ + "Type": "bind", + "Source": qdrant_root, + "Destination": "/qdrant/storage", + }], + }]) + elif command[1:3] == ["network", "inspect"]: + stdout = json.dumps([{ + "Id": network_id, + "Name": f"{namespace}_default", + "Created": "2026-07-12T12:00:00Z", + "Labels": { + "com.docker.compose.project": namespace, + "com.docker.compose.network": "default", + }, + "Containers": { + container_id: {"Name": f"{namespace}-qdrant"} + }, + }]) else: - raise AssertionError("fallback fake context should fail the gate") - finally: - os.environ.pop("CODESTORY_FAKE_FAIL_LAYER", None) - - os.environ["CODESTORY_FAKE_FAIL_LAYER"] = "serve_timeout" - timeout_args = argparse.Namespace(**vars(args)) - timeout_args.timeout_secs = 5 - try: + stdout = "removed" + return subprocess.CompletedProcess(command, 0, stdout, "") + + cleanup_artifact = root / "compose-cleanup.json" + cleanup_proof_cache( + None, + root, + compose_cache, + cleanup_artifact, + fake_docker, + registered_sidecars=[identity], + owned_delete=fake_owned_delete, + ) + assert not compose_cache.exists() + assert [command[1] for command in compose_calls if "rm" in command] == [ + "container", + "network", + ] + + for state_file_name in (SIDECAR_STATE_FILE_V3, LEGACY_SIDECAR_STATE_FILE): + global_cache = root / f"global-local-cache-{state_file_name}" + global_cache.mkdir() + write_json(global_cache / state_file_name, {"owner": "codestory"}) try: - run_gate(timeout_args) - except GateFailure as exc: - assert exc.layer == "serve_stdio" - assert "serve-stdio-status.json" in str(exc.artifact) + cleanup_proof_cache( + None, + root, + global_cache, + root / "global-local-cleanup.json", + owned_delete=fake_owned_delete, + ) + except RuntimeError as exc: + assert "global local-sidecar namespace" in str(exc) else: - raise AssertionError("silent fake stdio server should fail the gate") - finally: - os.environ.pop("CODESTORY_FAKE_FAIL_LAYER", None) + raise AssertionError("proof cleanup must refuse the global namespace") + remove_tree_with_retry(global_cache) - os.environ["CODESTORY_FAKE_FAIL_LAYER"] = "resources_hidden" - try: - try: - run_gate(args) - except GateFailure as exc: - assert exc.layer == "serve_stdio" - assert "serve-stdio-status.json" in str(exc.artifact) - else: - raise AssertionError("hidden MCP resources should fail the gate") - finally: - os.environ.pop("CODESTORY_FAKE_FAIL_LAYER", None) + registered_root = ( + proof_root_parent + / f"codestory-metal-proof-owned-self-test-{root.name}" + ) + registered_root.mkdir() + project = root / "repo" + project.mkdir() + archive = registered_root / "codestory-cli-v9.9.9-macos-arm64.tar.gz" + archive.write_bytes(b"packaged proof") + registered_cache = registered_root / "codestory-packaged-proof-cache-self-test" + registered_identity = write_proof_compose_state( + registered_cache, + {"compose_file": None}, + state_project=project, + ) + write_json( + registered_root / PROOF_TEMP_OWNER_FILE, + { + "owner": "codestory-macos-metal-proof", + "repository": os.environ.get("GITHUB_REPOSITORY"), + "project": str(project), + "cache_roots": [str(registered_cache)], + "sidecars": [registered_identity], + "launches": [], + "ports": [], + "archive_name": archive.name, + "archive_sha256": sha256_file(archive), + }, + ) + cleanup_out = root / "registered-cleanup-out" + cleanup_registered_proof_temp_root( + argparse.Namespace( + project=str(project), + out_dir=str(cleanup_out), + cleanup_proof_temp_root=str(registered_root), + owned_delete=fake_owned_delete, + ) + ) + assert not registered_root.exists() + cleanup = read_json_file(cleanup_out / "proof-owned-cleanup.json") + assert cleanup["root_removed"] is True + assert cleanup["cache_cleanup"][0]["status"] == "removed" print("self-test passed") @@ -1948,7 +4258,6 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--project", default=".", help="Representative repository to prove against.") parser.add_argument("--out-dir", default="target/packaged-agent-proof", help="Artifact directory.") parser.add_argument("--query", default=DEFAULT_QUERY, help="Search proof query.") - parser.add_argument("--context-query", default=DEFAULT_QUERY, help="Context proof target query.") parser.add_argument("--question", default=DEFAULT_QUESTION, help="Packet proof question.") parser.add_argument("--expected-version", help="Expected codestory-cli version in the archive.") parser.add_argument("--checksum-file", help="SHA256SUMS file that must contain and match the archive.") @@ -1964,16 +4273,57 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Prove managed plugin provisioning, local ground, and background repair handoff without requiring full sidecars.", ) + parser.add_argument( + "--native-accelerator-lifecycle", + action="store_true", + help="Require native accelerated runtime survival, dead-endpoint blocking, and repair recovery during the full proof.", + ) + parser.add_argument( + "--managed-plugin-grounding-convergence", + action="store_true", + help="Reach initial full retrieval through managed-plugin grounding activation without explicit repair.", + ) + parser.add_argument( + "--intel-runtime-policy", + action="store_true", + help="On Intel macOS, prove default backend failure plus explicitly labelled CPU/external operation without Metal claims.", + ) + parser.add_argument( + "--cleanup-proof-temp-root", + help="Clean only a marker-owned hardware-proof temp root and verify its registered processes and ports are gone.", + ) parser.add_argument("--self-test", action="store_true", help="Run script self-tests.") args = parser.parse_args() - if not args.self_test and not args.archive: - parser.error("--archive is required unless --self-test is set") - if not args.self_test and not args.expected_version: - parser.error("--expected-version is required unless --self-test is set") + if not args.self_test and not args.cleanup_proof_temp_root and not args.archive: + parser.error("--archive is required unless --self-test or cleanup mode is set") + if not args.self_test and not args.cleanup_proof_temp_root and not args.expected_version: + parser.error("--expected-version is required unless --self-test or cleanup mode is set") if args.managed_plugin_handoff and not args.plugin_root: parser.error("--plugin-root is required with --managed-plugin-handoff") + if args.managed_plugin_grounding_convergence and not args.plugin_root: + parser.error("--plugin-root is required with --managed-plugin-grounding-convergence") + if args.managed_plugin_grounding_convergence and not args.native_accelerator_lifecycle: + parser.error("--managed-plugin-grounding-convergence requires --native-accelerator-lifecycle") + if args.managed_plugin_grounding_convergence and args.managed_plugin_handoff: + parser.error("--managed-plugin-grounding-convergence and --managed-plugin-handoff are mutually exclusive") if args.managed_plugin_handoff and args.version_only: parser.error("--managed-plugin-handoff and --version-only are mutually exclusive") + if args.intel_runtime_policy and (args.managed_plugin_handoff or args.version_only): + parser.error("--intel-runtime-policy is mutually exclusive with --managed-plugin-handoff and --version-only") + if args.native_accelerator_lifecycle and (args.managed_plugin_handoff or args.version_only): + parser.error("--native-accelerator-lifecycle requires the full packaged proof") + if args.native_accelerator_lifecycle and args.intel_runtime_policy: + parser.error("--native-accelerator-lifecycle and --intel-runtime-policy are mutually exclusive") + if args.native_accelerator_lifecycle and os.name == "nt": + parser.error("--native-accelerator-lifecycle is supported only on POSIX hosts") + if args.cleanup_proof_temp_root and ( + args.native_accelerator_lifecycle + or args.intel_runtime_policy + or args.managed_plugin_handoff + or args.managed_plugin_grounding_convergence + or args.version_only + ): + parser.error("--cleanup-proof-temp-root is a standalone cleanup mode") return args @@ -1982,6 +4332,9 @@ def main() -> None: if args.self_test: self_test() return + if args.cleanup_proof_temp_root: + cleanup_registered_proof_temp_root(args) + return try: run_gate(args) except GateFailure as exc: diff --git a/.github/scripts/check-runtime-config-boundary.mjs b/.github/scripts/check-runtime-config-boundary.mjs new file mode 100644 index 00000000..26997f8b --- /dev/null +++ b/.github/scripts/check-runtime-config-boundary.mjs @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import path from "node:path"; + +const roots = [ + "crates/codestory-cli/src", + "crates/codestory-retrieval/src", + "crates/codestory-runtime/src", +]; +const forbidden = [ + "std::env::set_var", + "std::env::remove_var", + "activate_embed_url", + "prepare_bundled_llamacpp_client_env_defaults", + "activate_retrieval_profile_env", + "CODESTORY_EMBED_LLAMACPP_URL_MANAGED", +]; +const lateStartupReads = [ + 'std::env::var(PROJECT_NETWORK_CONFIG_OPT_IN_ENV)', + 'std::env::var_os("CODESTORY_STDIO_CACHE_ROOT")', +]; + +function rustFiles(root) { + return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const child = path.join(root, entry.name); + return entry.isDirectory() ? rustFiles(child) : entry.name.endsWith(".rs") ? [child] : []; + }); +} + +const violations = []; +for (const file of roots.flatMap(rustFiles)) { + const source = fs.readFileSync(file, "utf8"); + const production = source.split(/\r?\n#\[cfg\(test\)\]\r?\nmod tests\s*\{/u, 1)[0]; + for (const token of forbidden) { + if (production.includes(token)) violations.push(`${file}: ${token}`); + } +} + +for (const file of [ + "crates/codestory-cli/src/runtime.rs", + "crates/codestory-cli/src/stdio_transport.rs", +]) { + const source = fs.readFileSync(file, "utf8"); + for (const token of lateStartupReads) { + if (source.includes(token)) violations.push(`${file}: late startup read ${token}`); + } +} + +const runtimeSource = fs.readFileSync("crates/codestory-cli/src/runtime.rs", "utf8"); +const runtimeSelection = runtimeSource + .split("fn new_with_startup", 2)[1] + ?.split("/// Open project", 1)[0]; +for (const token of ["user_cache_root()", "for_project_auto_with_defaults("]) { + if (runtimeSelection?.includes(token)) { + violations.push(`crates/codestory-cli/src/runtime.rs: project selection ${token}`); + } +} + +if (violations.length) { + console.error("Production runtime configuration must remain immutable:\n" + violations.join("\n")); + process.exit(1); +} +console.log("runtime config boundary ok"); diff --git a/.github/scripts/check-workflow-policy.mjs b/.github/scripts/check-workflow-policy.mjs index 4090c8e8..1754e1eb 100644 --- a/.github/scripts/check-workflow-policy.mjs +++ b/.github/scripts/check-workflow-policy.mjs @@ -1,305 +1,746 @@ #!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { LineCounter, parseDocument } from "yaml"; const workflowRoot = path.join(".github", "workflows"); -const trustedOwners = new Set(["actions", "github"]); -const shaPattern = /^[0-9a-f]{40}$/i; -const violations = []; -const sagaIssueLinkGuard = path.join(workflowRoot, "saga-issue-link-guard.yml"); -const closeDevIssues = path.join(workflowRoot, "close-dev-issues.yml"); -const pluginStatic = path.join(workflowRoot, "plugin-static.yml"); -const rustCi = path.join(workflowRoot, "rust-ci.yml"); -const releaseWorkflow = path.join(workflowRoot, "release.yml"); -const postPublishReleaseSmoke = path.join(workflowRoot, "post-publish-release-smoke.yml"); -const packagedPlatformPr = path.join(workflowRoot, "packaged-platform-pr.yml"); -const packagedPlatformProof = path.join(workflowRoot, "packaged-platform-proof.yml"); -const mainBranchSourceGuard = path.join(workflowRoot, "main-branch-source-guard.yml"); - -for (const file of fs - .readdirSync(workflowRoot) - .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"))) { - const workflowPath = path.join(workflowRoot, file); - const content = fs.readFileSync(workflowPath, "utf8"); - - content.split(/\r?\n/).forEach((line, index) => { - const match = line.match(/\buses:\s*['"]?([^'"\s#]+)['"]?/); - if (!match) return; - - const spec = match[1]; - if (spec.startsWith("./")) return; - - const at = spec.lastIndexOf("@"); - if (at === -1) { - violations.push(`${file}:${index + 1} ${spec} is missing a ref`); - return; - } +const trustedActionOwners = new Set(["actions", "github"]); +const fullSha = /^[0-9a-f]{40}$/iu; - const action = spec.slice(0, at); - const ref = spec.slice(at + 1); - const owner = action.split("/")[0]; - - if (!trustedOwners.has(owner) && !shaPattern.test(ref)) { - violations.push( - `${file}:${index + 1} ${spec} must pin third-party actions to a full-length SHA`, - ); - } - }); +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {}; } -if (fs.existsSync(sagaIssueLinkGuard)) { - const content = fs.readFileSync(sagaIssueLinkGuard, "utf8"); - const closingRef = - /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:#\d+|https:\/\/github\.com\/TheGreenCedar\/CodeStory\/issues\/\d+)\b/im; +function list(value) { + if (value === undefined || value === null) return []; + return Array.isArray(value) ? value : [value]; +} - if (!content.includes("review/codestory-saga-")) { - violations.push("saga-issue-link-guard.yml must guard review/codestory-saga-* branches"); +function at(value, ...keys) { + let current = value; + for (const key of keys) { + if (current === null || typeof current !== "object") return undefined; + current = current[key]; } + return current; +} - if ( - !content.includes( - 'r"(?:#\\d+|https://github\\.com/TheGreenCedar/CodeStory/issues/\\d+)\\b"', - ) - ) { - violations.push("saga-issue-link-guard.yml closing refs must require # or a full issue URL"); +function scalarStrings(value, found = []) { + if (typeof value === "string") { + found.push(value); + } else if (Array.isArray(value)) { + for (const item of value) scalarStrings(item, found); + } else if (value !== null && typeof value === "object") { + for (const item of Object.values(value)) scalarStrings(item, found); } + return found; +} - if ( - closingRef.test("Closes 123") || - !closingRef.test("Closes #123") || - !closingRef.test("Closes https://github.com/TheGreenCedar/CodeStory/issues/123") - ) { - violations.push("saga-issue-link-guard.yml closing ref policy must reject bare numbers and accept # or full issue URLs"); - } +function includesAll(values, expected) { + const present = new Set(list(values)); + return expected.every(value => present.has(value)); } -if (!fs.existsSync(closeDevIssues)) { - violations.push("close-dev-issues.yml must close linked issues for merged dev PRs"); -} else { - const content = fs.readFileSync(closeDevIssues, "utf8"); - const requiredSnippets = [ - "push:", - "dev/codestory-next", - 'commit = event["after"]', - 'pull_request.get("merged_at")', - 'pull_request.get("merge_commit_sha") == commit', - "issues: write", - 'if "pull_request" in issue:', - '"state_reason=completed"', - ]; +function sameMembers(actual, expected) { + const left = [...new Set(list(actual))].sort(); + const right = [...new Set(expected)].sort(); + return JSON.stringify(left) === JSON.stringify(right); +} - for (const snippet of requiredSnippets) { - if (!content.includes(snippet)) { - violations.push(`close-dev-issues.yml must include ${snippet}`); - } +function needs(job) { + return list(job?.needs); +} + +function namedStep(job, name) { + const matches = list(job?.steps).filter(step => object(step).name === name); + return matches.length === 1 ? matches[0] : undefined; +} + +function stepRun(job, name) { + const run = namedStep(job, name)?.run; + return typeof run === "string" ? run : ""; +} + +function executableRunText(run) { + return run + .split(/\r?\n/u) + .filter(line => !/^\s*#/u.test(line)) + .join("\n"); +} + +function add(violations, condition, message) { + if (!condition) violations.push(message); +} + +function requireStepRun(violations, file, job, name, fragments) { + const run = executableRunText(stepRun(job, name)); + add(violations, run.length > 0, `${file} must contain named step ${name}`); + for (const fragment of fragments) { + add( + violations, + run.includes(fragment), + `${file} step ${name} must run ${fragment}`, + ); } +} - if ( - !content.includes( - 'r"(?:#(\\d+)|https://github\\.com/TheGreenCedar/CodeStory/issues/(\\d+))\\b"', - ) - ) { - violations.push("close-dev-issues.yml must accept only # or same-repository issue URLs"); - } -} - -if (!fs.existsSync(pluginStatic)) { - violations.push("plugin-static.yml must run plugin static tests for plugin changes"); -} else { - const content = fs.readFileSync(pluginStatic, "utf8"); - const requiredSnippets = [ - "plugins/codestory/**", - "dev/codestory-next", - "node --test plugins/codestory/tests/plugin-static.test.mjs", - "node .github/scripts/check-workflow-policy.mjs", - "python .github/scripts/check-packaged-agent-proof.py --self-test", - "python .github/scripts/package-codestory-release.py --self-test", - "python .github/scripts/check-codestory-release.py --version", - ".github/scripts/check-packaged-agent-proof.py", - ".github/scripts/package-codestory-release.py", - ".github/workflows/release.yml", - ".github/workflows/post-publish-release-smoke.yml", - ".github/workflows/packaged-platform-pr.yml", - ".github/workflows/packaged-platform-proof.yml", - "scripts/install-codestory.ps1", - ]; +function requireStepUses(violations, file, job, name, expected) { + add( + violations, + namedStep(job, name)?.uses === expected, + `${file} step ${name} must use ${expected}`, + ); +} - for (const snippet of requiredSnippets) { - if (!content.includes(snippet)) { - violations.push(`plugin-static.yml must include ${snippet}`); - } +function requireJob(violations, file, workflow, name) { + const found = object(workflow.jobs)[name]; + add(violations, found !== undefined, `${file} must contain job ${name}`); + return object(found); +} + +export function parseWorkflow(source, file = "workflow") { + const document = parseDocument(source, { + lineCounter: new LineCounter(), + prettyErrors: true, + schema: "core", + strict: true, + uniqueKeys: true, + }); + if (document.errors.length > 0) { + throw new Error(document.errors.map(error => error.message).join("\n")); } + const parsed = document.toJS({ maxAliasCount: 50 }); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${file} must contain one YAML mapping`); + } + return parsed; } -if (!fs.existsSync(rustCi)) { - violations.push("rust-ci.yml must run default Rust workspace checks for routine code changes"); -} else { - const content = fs.readFileSync(rustCi, "utf8"); - const requiredSnippets = [ - "Cargo.lock", - "Cargo.toml", - "crates/**", - "dev/codestory-next", - "cargo fmt --check", - "cargo check --workspace --locked", - "cargo test --locked", - "cargo clippy --workspace --all-targets --all-features -- -D warnings", - ]; +function loadWorkflows(root = workflowRoot) { + const loaded = new Map(); + for (const file of fs.readdirSync(root).filter(name => /\.ya?ml$/u.test(name)).sort()) { + const source = fs.readFileSync(path.join(root, file), "utf8"); + loaded.set(file, parseWorkflow(source, file)); + } + return loaded; +} - for (const snippet of requiredSnippets) { - if (!content.includes(snippet)) { - violations.push(`rust-ci.yml must include ${snippet}`); - } +function trigger(workflow, name) { + return object(workflow.on)[name]; +} + +function concurrencyCancels(workflow) { + return object(workflow.concurrency)["cancel-in-progress"] === true; +} + +function executableCargoLines(run) { + return run + .split(/\r?\n/u) + .map((line, index) => ({ line, number: index + 1 })) + .filter(({ line }) => + /^\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)*(?:sudo\s+)?cargo\s+(?:build|check|test|clippy|doc|run)\b/u.test(line), + ); +} + +function walk(value, visit, trail = []) { + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, visit, [...trail, index])); + return; + } + if (value === null || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + visit(key, child, [...trail, key]); + walk(child, visit, [...trail, key]); } } -if (!fs.existsSync(releaseWorkflow)) { - violations.push("release.yml must exist for release automation"); -} else { - const content = fs.readFileSync(releaseWorkflow, "utf8"); - if (content.includes("rustup toolchain install stable")) { - violations.push("release.yml release builds must not install floating stable Rust"); +export function basicWorkflowViolations(file, workflow) { + const violations = []; + const triggers = object(workflow.on); + if (triggers.pull_request !== undefined || triggers.pull_request_target !== undefined) { + add( + violations, + concurrencyCancels(workflow), + `${file} pull-request runs must cancel stale work`, + ); } - for (const snippet of [ - "uses: ./.github/workflows/packaged-platform-proof.yml", - "version: ${{ needs.preflight.outputs.version }}", - "uses: ./.github/workflows/post-publish-release-smoke.yml", - "--notes-file target/release-assets/proof-boundaries.md", - "live managed Metal endpoint survival remains open in #887", - "Older-glibc compatibility is unproven", - "--generate-notes", - ]) { - if (!content.includes(snippet)) { - violations.push(`release.yml must include ${snippet}`); + + walk(workflow, (key, value, trail) => { + if (key === "key" && typeof value === "string" && value.includes("github.sha")) { + violations.push(`${file} ${trail.join(".")} Cargo cache key must not include commit SHA`); } - } - for (const row of [ - "needs:\n - preflight\n - publish\n uses: ./.github/workflows/post-publish-release-smoke.yml", - ]) { - if (!content.includes(row)) { - violations.push(`release.yml must preserve release proof block ${row.split("\n")[0]}`); + if (key !== "uses" || typeof value !== "string" || value.startsWith("./")) return; + const separator = value.lastIndexOf("@"); + if (separator < 0) { + violations.push(`${file} ${value} is missing an action ref`); + return; + } + const owner = value.slice(0, separator).split("/")[0]; + const ref = value.slice(separator + 1); + if (!trustedActionOwners.has(owner) && !fullSha.test(ref)) { + violations.push(`${file} ${value} must pin third-party actions to a full-length SHA`); + } + }); + + for (const [jobName, job] of Object.entries(object(workflow.jobs))) { + for (const [stepIndex, step] of list(object(job).steps).entries()) { + if (typeof step?.run !== "string") continue; + for (const { line, number } of executableCargoLines(step.run)) { + if (!/(?:^|\s)--locked(?:\s|$)/u.test(line)) { + violations.push( + `${file} jobs.${jobName}.steps.${stepIndex}.run:${number} dependency-resolving Cargo command must use --locked`, + ); + } + } } } + return violations; } -if (!fs.existsSync(packagedPlatformProof)) { - violations.push("packaged-platform-proof.yml must own the reusable native package matrix"); -} else { - const content = fs.readFileSync(packagedPlatformProof, "utf8"); - for (const snippet of [ - "workflow_call:", - "contents: read", - 'RELEASE_RUST_TOOLCHAIN: "1.95.0"', - "packaged-version-proof-${{ matrix.asset_target }}", +export function managedPluginViolations(job, archiveFragment) { + const violations = []; + const strategy = object(job.strategy); + const matrix = strategy.matrix; + const step = namedStep(job, "Prove managed plugin handoff"); + add(violations, strategy["fail-fast"] === false, "managed plugin matrix must set fail-fast to false"); + add(violations, job.if === undefined, "managed plugin job must not be conditional"); + add(violations, job["continue-on-error"] === undefined, "managed plugin job must not continue on error"); + add( + violations, + typeof matrix === "string" || object(matrix).exclude === undefined, + "managed plugin matrix must not exclude cells", + ); + add(violations, step !== undefined, "managed plugin proof step is missing"); + add(violations, step?.if === undefined, "managed plugin proof step must not be conditional"); + add( + violations, + step?.["continue-on-error"] === undefined, + "managed plugin proof step must not continue on error", + ); + const run = executableRunText(typeof step?.run === "string" ? step.run : ""); + for (const fragment of [ + "python .github/scripts/check-packaged-agent-proof.py", + archiveFragment, "--managed-plugin-handoff", - "scripts/install-codestory.ps1 -SelfTest", - "--checksum-file target/release-dist/SHA256SUMS.txt", ]) { - if (!content.includes(snippet)) { - violations.push(`packaged-platform-proof.yml must include ${snippet}`); - } + add(violations, run.includes(fragment), `managed plugin proof step must run ${fragment}`); } - for (const row of [ - "- os: ubuntu-latest\n rust_target: x86_64-unknown-linux-gnu\n asset_target: linux-x64", - "- os: ubuntu-24.04-arm\n rust_target: aarch64-unknown-linux-gnu\n asset_target: linux-arm64", - "- os: windows-latest\n rust_target: x86_64-pc-windows-msvc\n asset_target: windows-x64", - "- os: windows-11-arm\n rust_target: aarch64-pc-windows-msvc\n asset_target: windows-arm64", - "- os: macos-15\n rust_target: aarch64-apple-darwin\n asset_target: macos-arm64", - "if: matrix.asset_target == 'windows-x64'\n shell: pwsh\n run: pwsh -File scripts/install-codestory.ps1 -SelfTest", - ]) { - if (!content.includes(row)) { - violations.push(`packaged-platform-proof.yml must preserve native proof block ${row.split("\n")[0]}`); + return violations; +} + +export function packagedPrSigningViolations(workflow) { + const violations = []; + const job = object(object(workflow.jobs)["packaged-proof"]); + add( + violations, + object(job.with).sign_macos === false, + "packaged-platform-pr.yml packaged-proof must set sign_macos to false", + ); + add( + violations, + job.secrets === undefined, + "packaged-platform-pr.yml packaged-proof must not receive caller secrets", + ); + const scalars = scalarStrings(workflow); + let referencesAppleSecret = false; + walk(workflow, (key, value) => { + if (/^APPLE_[A-Z0-9_]+$/u.test(key)) referencesAppleSecret = true; + if (typeof value === "string" && /\bAPPLE_[A-Z0-9_]+\b/u.test(value)) { + referencesAppleSecret = true; } - } + }); + add( + violations, + !referencesAppleSecret, + "packaged-platform-pr.yml must not reference Apple secret identifiers", + ); + add( + violations, + !scalars.some(value => value.includes("macos-release-signing")), + "packaged-platform-pr.yml must not reference the release signing environment", + ); + return violations; } -if (!fs.existsSync(postPublishReleaseSmoke)) { - violations.push("post-publish-release-smoke.yml must exist for native published-asset proof"); -} else { - const content = fs.readFileSync(postPublishReleaseSmoke, "utf8"); - for (const snippet of [ - "workflow_call:", - "ubuntu-latest", - "ubuntu-24.04-arm", - "windows-latest", - "windows-11-arm", - "macos-15", - "linux-x64", - "linux-arm64", - "windows-x64", - "windows-arm64", - "macos-arm64", - "--version-only", - "--managed-plugin-handoff", - "scripts/install-codestory.ps1 -SelfTest", - "--checksum-file \"${{ steps.asset.outputs.checksum }}\"", - ]) { - if (!content.includes(snippet)) { - violations.push(`post-publish-release-smoke.yml must include ${snippet}`); +export function notaryStepViolations(step) { + const run = typeof step?.run === "string" ? step.run : ""; + return run + .split(/\r?\n/u) + .some(line => /^\s*--wait(?:\s|\\|$)/u.test(line) || /notarytool\s+submit.*\s--wait(?:\s|$)/u.test(line)) + ? ["notarization must poll explicitly instead of using notarytool --wait"] + : []; +} + +function validateLockedSetupSurfaces(violations) { + const contracts = new Map([ + [ + path.join(".cargo", "config.toml"), + [ + 'retrieval-setup = "run --locked -p codestory-cli', + 'retrieval-status = "run --locked -p codestory-cli', + ], + ], + [path.join("scripts", "codex-worktree-setup.mjs"), ['["build", "--release", "--locked", "-p", "codestory-cli"]']], + [path.join("plugins", "codestory", "skills", "codestory-grounding", "scripts", "setup.sh"), ["cargo build --release --locked -p codestory-cli"]], + [path.join("plugins", "codestory", "skills", "codestory-grounding", "scripts", "setup.ps1"), ['@("build", "--release", "--locked", "-p", "codestory-cli"']], + [path.join("scripts", "setup-retrieval-env.mjs"), ['return ["run", "--locked", ...args]']], + ]); + for (const [file, fragments] of contracts) { + const source = fs.readFileSync(file, "utf8"); + for (const fragment of fragments) { + add(violations, source.includes(fragment), `${file} must preserve locked Cargo contract ${fragment}`); } } - for (const row of [ - "- os: ubuntu-latest\n asset_target: linux-x64\n extension: tar.gz", - "- os: ubuntu-24.04-arm\n asset_target: linux-arm64\n extension: tar.gz", - "- os: windows-latest\n asset_target: windows-x64\n extension: zip", - "- os: windows-11-arm\n asset_target: windows-arm64\n extension: zip", - "- os: macos-15\n asset_target: macos-arm64\n extension: tar.gz", - "if: matrix.asset_target == 'windows-x64'\n shell: pwsh\n run: pwsh -File scripts/install-codestory.ps1 -SelfTest", - "if: matrix.asset_target == 'windows-x64'\n shell: pwsh\n run: >-\n python .github/scripts/check-packaged-agent-proof.py", - ]) { - if (!content.includes(row)) { - violations.push(`post-publish-release-smoke.yml must preserve native proof block ${row.split("\n")[0]}`); +} + +function validateIssueWorkflows(workflows, violations) { + const sagaFile = "saga-issue-link-guard.yml"; + const saga = workflows.get(sagaFile); + if (!saga) { + violations.push(`${sagaFile} must exist`); + } else { + add(violations, trigger(saga, "pull_request_target") !== undefined, `${sagaFile} must use pull_request_target`); + add(violations, object(saga.permissions)["pull-requests"] === "read", `${sagaFile} must read pull requests`); + const job = requireJob(violations, sagaFile, saga, "require-closing-issue-link"); + for (const fragment of ["codex/", "review/codestory-saga-", "[codex]", "saga:codestory-intelligence"]) { + add(violations, String(job.if ?? "").includes(fragment), `${sagaFile} guarded condition must include ${fragment}`); } + requireStepRun(violations, sagaFile, job, "Check PR issue relationship", [ + "close[sd]?|fix(?:e[sd])?|resolve[sd]?", + "#\\d+|https://github\\.com/TheGreenCedar/CodeStory/issues/\\d+", + ]); } - if (content.includes("sha256sum")) { - violations.push("post-publish-release-smoke.yml must use the portable Python checksum gate"); + + const closeFile = "close-dev-issues.yml"; + const close = workflows.get(closeFile); + if (!close) { + violations.push(`${closeFile} must exist`); + } else { + add(violations, includesAll(at(close, "on", "push", "branches"), ["dev/codestory-next"]), `${closeFile} must run on dev/codestory-next pushes`); + add(violations, object(close.permissions).issues === "write", `${closeFile} must write issues`); + add(violations, object(close.permissions)["pull-requests"] === "read", `${closeFile} must read pull requests`); + const job = requireJob(violations, closeFile, close, "close-linked-issues"); + requireStepRun(violations, closeFile, job, "Close issues referenced by the merged PR", [ + 'commit = event["after"]', + 'pull_request.get("merged_at")', + 'pull_request.get("merge_commit_sha") == commit', + 'if "pull_request" in issue:', + '"state_reason=completed"', + "https://github\\.com/TheGreenCedar/CodeStory/issues/(\\d+)", + ]); } } -if (!fs.existsSync(packagedPlatformPr)) { - violations.push("packaged-platform-pr.yml must run the native package matrix on implementation PRs"); -} else { - const content = fs.readFileSync(packagedPlatformPr, "utf8"); - for (const snippet of [ - "pull_request:", - "contents: read", - "uses: ./.github/workflows/packaged-platform-proof.yml", - "version: ${{ needs.prepare.outputs.version }}", - ".github/scripts/check-packaged-agent-proof.py", - ".github/workflows/post-publish-release-smoke.yml", - ".github/workflows/packaged-platform-proof.yml", - ]) { - if (!content.includes(snippet)) { - violations.push(`packaged-platform-pr.yml must include ${snippet}`); +function validatePluginAndDraftWorkflows(workflows, violations) { + const pluginFile = "plugin-static.yml"; + const plugin = workflows.get(pluginFile); + if (!plugin) { + violations.push(`${pluginFile} must exist`); + } else { + const requiredPaths = [ + "plugins/codestory/**", + ".github/scripts/check-workflow-policy.mjs", + ".github/scripts/check-workflow-policy.test.mjs", + ".github/workflows/release.yml", + ".github/workflows/packaged-platform-pr.yml", + ".github/workflows/packaged-platform-proof.yml", + ".github/workflows/macos-metal-proof.yml", + ".github/workflows/source-proof.yml", + ".github/workflows/repo-scale-stats.yml", + "package.json", + "package-lock.json", + "scripts/codex-worktree-setup.*", + "scripts/setup-retrieval-env.*", + "scripts/install-codestory.ps1", + ]; + for (const event of ["pull_request", "push"]) { + add(violations, includesAll(at(plugin, "on", event, "paths"), requiredPaths), `${pluginFile} ${event} paths must cover policy and release surfaces`); } + add(violations, includesAll(at(plugin, "on", "push", "branches"), ["dev/codestory-next"]), `${pluginFile} must run on dev pushes`); + const job = requireJob(violations, pluginFile, plugin, "plugin-static"); + requireStepRun(violations, pluginFile, job, "Install workflow policy dependencies", ["npm ci --ignore-scripts"]); + requireStepRun(violations, pluginFile, job, "Check workflow policy", [ + "node .github/scripts/check-workflow-policy.mjs", + "node --test .github/scripts/check-workflow-policy.test.mjs", + ]); + requireStepRun(violations, pluginFile, job, "Check plugin static wiring", ["node --test plugins/codestory/tests/plugin-static.test.mjs"]); + requireStepRun(violations, pluginFile, job, "Check CI proof routing fixtures", ["node .github/scripts/route-ci-proof.mjs --self-test"]); + requireStepRun(violations, pluginFile, job, "Check packaged proof harness", ["python .github/scripts/check-packaged-agent-proof.py --self-test"]); } - if (content.includes("contents: write") || content.includes("uses: ./.github/workflows/release.yml")) { - violations.push("packaged-platform-pr.yml must not request release permissions or call the publishing workflow"); + + const rustFile = "rust-ci.yml"; + const rust = workflows.get(rustFile); + if (!rust) { + violations.push(`${rustFile} must exist`); + } else { + add(violations, trigger(rust, "push") === undefined, `${rustFile} draft checks must not run on push`); + add(violations, includesAll(at(rust, "on", "pull_request", "paths"), ["Cargo.lock", "Cargo.toml", "crates/**"]), `${rustFile} must cover workspace source changes`); + const job = requireJob(violations, rustFile, rust, "linux-draft"); + add(violations, job["runs-on"] === "ubuntu-latest", `${rustFile} must use one Ubuntu lane`); + requireStepRun(violations, rustFile, job, "Check formatting", ["cargo fmt --check"]); + requireStepRun(violations, rustFile, job, "Check the workspace", ["cargo check --workspace --locked"]); + requireStepRun(violations, rustFile, job, "Lint workspace libraries", ["cargo clippy --workspace --lib --locked -- -D warnings"]); + requireStepRun(violations, rustFile, job, "Prove focused publication contracts", ["cargo test --locked"]); + } + + const sourceFile = "source-proof.yml"; + const source = workflows.get(sourceFile); + if (!source) { + violations.push(`${sourceFile} must exist`); + } else { + add(violations, sameMembers(at(source, "on", "pull_request", "types"), ["labeled", "synchronize"]), `${sourceFile} pull request types must be labeled and synchronize`); + add(violations, trigger(source, "pull_request_target") === undefined, `${sourceFile} must not execute pull-request code through pull_request_target`); + const resolve = requireJob(violations, sourceFile, source, "resolve"); + add(violations, String(resolve.if ?? "").includes("review-accepted"), `${sourceFile} resolve job must require review-accepted`); + requireStepRun(violations, sourceFile, resolve, "Resolve trusted exact head", [ + 'test "$EVENT_HEAD_REPO" = "$GITHUB_REPOSITORY"', + 'test "$current_head" = "$EVENT_HEAD_SHA"', + ]); + const full = requireJob(violations, sourceFile, source, "full-source-gate"); + add(violations, sameMembers(needs(full), ["resolve"]), `${sourceFile} full source gate must need resolve`); + requireStepRun(violations, sourceFile, full, "Test the complete workspace once", ["cargo test --workspace --locked"]); + requireStepRun(violations, sourceFile, full, "Lint every workspace target and feature once", ["cargo clippy --workspace --all-targets --all-features --locked -- -D warnings"]); } } -if (!fs.existsSync(mainBranchSourceGuard)) { - violations.push("main-branch-source-guard.yml must guard PRs into main"); -} else { - const content = fs.readFileSync(mainBranchSourceGuard, "utf8"); - const requiredSnippets = [ - "pull_request:", - "- main", - "dev/codestory-next", - "HEAD_REPO", - "BASE_REPO", +function validateReleaseCoordinator(workflows, violations) { + const releaseFile = "release.yml"; + const release = workflows.get(releaseFile); + if (!release) { + violations.push(`${releaseFile} must exist`); + return; + } + add(violations, object(release.permissions).actions === "read", `${releaseFile} must read prior-run evidence`); + for (const event of ["workflow_call", "workflow_dispatch"]) { + const input = object(at(release, "on", event, "inputs", "source_run_id")); + add(violations, input.required === false && input.type === "string" && input.default === "", `${releaseFile} ${event} source_run_id must be an optional empty string`); + } + const policy = requireJob(violations, releaseFile, release, "workflow-policy"); + requireStepRun(violations, releaseFile, policy, "Install workflow policy dependencies", ["npm ci --ignore-scripts"]); + requireStepRun(violations, releaseFile, policy, "Enforce workflow policy", ["node .github/scripts/check-workflow-policy.mjs"]); + + const preflight = requireJob(violations, releaseFile, release, "preflight"); + add(violations, sameMembers(needs(preflight), ["workflow-policy"]), `${releaseFile} preflight must need workflow-policy`); + requireStepRun(violations, releaseFile, preflight, "Validate versioned changelog notes", [ + "node .github/scripts/extract-codestory-release-notes.mjs", + '--version "$VERSION"', + ]); + + const evidence = requireJob(violations, releaseFile, release, "release-evidence"); + add(violations, evidence.uses === "./.github/workflows/release-candidate-evidence.yml", `${releaseFile} release-evidence must call the evidence workflow`); + add(violations, sameMembers(needs(evidence), ["preflight"]), `${releaseFile} release-evidence must need preflight`); + for (const [key, value] of Object.entries({ + ref: "${{ github.sha }}", + proof_key: "release-${{ needs.preflight.outputs.version }}", + profile: "codestory-release-evidence-linux-arm64-v1", + drill_manifest: "/srv/codestory-release-evidence/drills/real-repo-drill-cases.json", + embedding_model_dir: "/srv/codestory-release-evidence/models", + source_run_id: "${{ inputs.source_run_id }}", + })) { + add(violations, object(evidence.with)[key] === value, `${releaseFile} release-evidence with.${key} must equal ${value}`); + } + + const packaged = requireJob(violations, releaseFile, release, "packaged-proof"); + add(violations, packaged.uses === "./.github/workflows/packaged-platform-proof.yml", `${releaseFile} packaged-proof must call the package workflow`); + add(violations, sameMembers(needs(packaged), ["preflight", "release-evidence"]), `${releaseFile} packaged-proof must wait for preflight and release evidence`); + add(violations, object(packaged.with).sign_macos === true, `${releaseFile} packaged-proof must sign Mac assets`); + const expectedSecrets = [ + "APPLE_DEVELOPER_ID_P12_BASE64", + "APPLE_DEVELOPER_ID_P12_PASSWORD", + "APPLE_SIGNING_IDENTITY", + "APPLE_NOTARY_KEY_P8_BASE64", + "APPLE_NOTARY_KEY_ID", + "APPLE_NOTARY_ISSUER_ID", ]; + add(violations, sameMembers(Object.keys(object(packaged.secrets)), expectedSecrets), `${releaseFile} packaged-proof must pass exactly the Apple signing secrets`); - for (const snippet of requiredSnippets) { - if (!content.includes(snippet)) { - violations.push(`main-branch-source-guard.yml must include ${snippet}`); - } + const metal = requireJob(violations, releaseFile, release, "macos-metal-proof"); + add(violations, metal.uses === "./.github/workflows/macos-metal-proof.yml", `${releaseFile} must call protected Metal proof`); + add(violations, sameMembers(needs(metal), ["preflight", "packaged-proof"]), `${releaseFile} Metal proof must wait for packaged proof`); + add(violations, object(metal.with).use_packaged_cli_artifact === true, `${releaseFile} Metal proof must use the packaged CLI`); + + const publish = requireJob(violations, releaseFile, release, "publish"); + add(violations, sameMembers(needs(publish), ["preflight", "packaged-proof", "macos-metal-proof"]), `${releaseFile} publish must wait for all release proofs`); + requireStepRun(violations, releaseFile, publish, "Compose versioned GitHub release notes", [ + "node .github/scripts/extract-codestory-release-notes.mjs", + "--output target/release-assets/release-notes.md", + ]); + requireStepRun(violations, releaseFile, publish, "Create GitHub release", [ + "--notes-file target/release-assets/release-notes.md", + 'if [ "${#assets[@]}" -ne 7 ]; then', + ]); + add(violations, !scalarStrings(release).some(value => value.includes("--generate-notes")), `${releaseFile} must use curated release notes`); + + const post = requireJob(violations, releaseFile, release, "post-publish-smoke"); + add(violations, post.uses === "./.github/workflows/post-publish-release-smoke.yml", `${releaseFile} must call post-publish smoke`); + add(violations, sameMembers(needs(post), ["preflight", "publish"]), `${releaseFile} post-publish smoke must wait for publish`); +} + +function expectedPackageRows() { + return [ + { os: "ubuntu-latest", rust_target: "x86_64-unknown-linux-gnu", asset_target: "linux-x64", exe_suffix: "", extension: "tar.gz" }, + { os: "ubuntu-24.04-arm", rust_target: "aarch64-unknown-linux-gnu", asset_target: "linux-arm64", exe_suffix: "", extension: "tar.gz" }, + { os: "windows-latest", rust_target: "x86_64-pc-windows-msvc", asset_target: "windows-x64", exe_suffix: ".exe", extension: "zip" }, + { os: "windows-11-arm", rust_target: "aarch64-pc-windows-msvc", asset_target: "windows-arm64", exe_suffix: ".exe", extension: "zip" }, + { os: "macos-15-intel", rust_target: "x86_64-apple-darwin", asset_target: "macos-x64", exe_suffix: "", extension: "tar.gz" }, + { os: "macos-15", rust_target: "aarch64-apple-darwin", asset_target: "macos-arm64", exe_suffix: "", extension: "tar.gz" }, + ]; +} + +function validatePackageMatrixExpression(violations, expression) { + const match = typeof expression === "string" && expression.match( + /fromJSON\(inputs\.scope == 'windows' && '([^']+)' \|\| inputs\.scope == 'macos' && '([^']+)' \|\| '([^']+)'\)/u, + ); + if (!match) { + violations.push("packaged-platform-proof.yml matrix must select structural JSON by scope"); + return; + } + const full = expectedPackageRows(); + const expected = [ + { include: full.filter(row => row.asset_target === "windows-x64") }, + { include: full.filter(row => row.asset_target.startsWith("macos-")) }, + { include: full }, + ]; + try { + match.slice(1).forEach((json, index) => { + add(violations, JSON.stringify(JSON.parse(json)) === JSON.stringify(expected[index]), "packaged-platform-proof.yml package matrix scope changed"); + }); + } catch { + violations.push("packaged-platform-proof.yml package matrix must contain valid JSON"); } } -if (violations.length > 0) { - console.error(violations.join("\n")); - process.exit(1); +function validatePackagedProof(workflows, violations) { + const file = "packaged-platform-proof.yml"; + const workflow = workflows.get(file); + if (!workflow) { + violations.push(`${file} must exist`); + return; + } + add(violations, trigger(workflow, "workflow_call") !== undefined, `${file} must be reusable`); + add(violations, object(workflow.permissions).contents === "read", `${file} must use read-only contents permission`); + add( + violations, + object(workflow.env).LINUX_GLIBC_BUILD_IMAGE === + "rust:1.95.0-bullseye@sha256:28afaeb8445f2a2e7d878bd34ed39ba02bb517efb29986188cbd59b7cf4f2fdf", + `${file} must pin the glibc build image`, + ); + const job = requireJob(violations, file, workflow, "build"); + validatePackageMatrixExpression(violations, at(job, "strategy", "matrix")); + add(violations, String(job.environment ?? "").includes("macos-release-signing"), `${file} signed Mac cells must use the protected signing environment`); + requireStepRun(violations, file, job, "Build Linux x64 at the glibc 2.31 baseline", [ + "cargo build --release --locked -p codestory-cli", + "CARGO_TARGET_DIR=/workspace/target/glibc-2.31", + ]); + const signing = namedStep(job, "Sign and notarize macOS CLI"); + add(violations, signing !== undefined, `${file} must sign and notarize Mac binaries`); + violations.push(...notaryStepViolations(signing).map(message => `${file} ${message}`)); + const signingRun = executableRunText(String(signing?.run ?? "")); + for (const fragment of [ + "umask 077", + "chmod 600", + "--options runtime", + "--timestamp", + "xcrun notarytool submit", + "--no-wait", + "xcrun notarytool info", + "xcrun notarytool log", + 'jq -e \'.status == "Accepted"\'', + "TeamIdentifier=${APPLE_DEVELOPER_TEAM_ID}", + "certificate leaf", + ]) { + add(violations, signingRun.includes(fragment), `${file} signing step must include ${fragment}`); + } + requireStepRun(violations, file, job, "Run Windows installer ownership self-test", ["scripts/install-codestory.ps1 -SelfTest"]); + requireStepRun(violations, file, job, "Prove Linux x64 glibc 2.31 baseline", ["bash .github/scripts/check-linux-glibc-baseline.sh"]); + violations.push(...managedPluginViolations( + job, + '--archive "target/release-dist/codestory-cli-v${{ inputs.version }}-${{ matrix.asset_target }}.${{ matrix.extension }}"', + ).map(message => `${file} ${message}`)); + requireStepUses(violations, file, job, "Upload release asset", "actions/upload-artifact@v7.0.1"); + requireStepUses(violations, file, job, "Upload macOS notarization proof", "actions/upload-artifact@v7.0.1"); +} + +function validatePostPublish(workflows, violations) { + const file = "post-publish-release-smoke.yml"; + const workflow = workflows.get(file); + if (!workflow) { + violations.push(`${file} must exist`); + return; + } + add(violations, trigger(workflow, "workflow_call") !== undefined, `${file} must be reusable`); + const job = requireJob(violations, file, workflow, "smoke"); + const expected = expectedPackageRows().map(({ os, asset_target, extension }) => ({ os, asset_target, extension })); + add(violations, JSON.stringify(at(job, "strategy", "matrix", "include")) === JSON.stringify(expected), `${file} must smoke all six native assets`); + violations.push(...managedPluginViolations( + job, + '--archive "${{ steps.asset.outputs.archive }}"', + ).map(message => `${file} ${message}`)); + requireStepRun(violations, file, job, "Prove published macOS signature, notarization, and Gatekeeper acceptance", [ + "archive-quarantine.txt", + "extracted-binary-quarantine.txt", + "Authority=Developer ID Application:", + "source=Notarized Developer ID", + "TeamIdentifier=${APPLE_DEVELOPER_TEAM_ID}", + "certificate leaf", + ]); + requireStepRun(violations, file, job, "Run Windows installer ownership self-test", ["scripts/install-codestory.ps1 -SelfTest"]); + requireStepRun(violations, file, job, "Prove published Intel macOS backend policy and explicit CPU/external operation", ["--intel-runtime-policy"]); + add(violations, !scalarStrings(workflow).some(value => value.includes("sha256sum")), `${file} must use the portable Python checksum gate`); +} + +function validatePackagedCoordinator(workflows, violations) { + const file = "packaged-platform-pr.yml"; + const workflow = workflows.get(file); + if (!workflow) { + violations.push(`${file} must exist`); + return; + } + add(violations, sameMembers(at(workflow, "on", "pull_request", "types"), ["labeled", "synchronize"]), `${file} pull request types must be labeled and synchronize`); + add(violations, includesAll(at(workflow, "on", "workflow_dispatch", "inputs", "mode", "options"), ["platform", "release-evidence", "integration"]), `${file} dispatch modes changed`); + add(violations, includesAll(at(workflow, "on", "workflow_dispatch", "inputs", "scope", "options"), ["auto", "windows", "macos", "full"]), `${file} dispatch scopes changed`); + add(violations, trigger(workflow, "pull_request_target") === undefined, `${file} must not use pull_request_target`); + add(violations, object(workflow.permissions).actions === "read", `${file} must read source-proof runs`); + add(violations, object(workflow.permissions).contents === "read", `${file} must use read-only contents permission`); + const route = requireJob(violations, file, workflow, "route"); + requireStepRun(violations, file, route, "Resolve trusted exact head", [ + 'test "$head_repo" = "$GITHUB_REPOSITORY"', + 'test "$current_head" = "$expected_head"', + 'test "$base_ref" = "dev/codestory-next"', + ]); + requireStepRun(violations, file, route, "Require successful exact-head source proof", [ + "actions/runs?head_sha=$HEAD_SHA", + '.path == ".github/workflows/source-proof.yml"', + '.name == "full-source-gate" and .conclusion == "success"', + ]); + requireStepRun(violations, file, route, "Select change-aware proof scope", ["node .github/scripts/route-ci-proof.mjs --stdin"]); + const packaged = requireJob(violations, file, workflow, "packaged-proof"); + add(violations, packaged.uses === "./.github/workflows/packaged-platform-proof.yml", `${file} must call packaged proof`); + violations.push(...packagedPrSigningViolations(workflow)); + const metal = requireJob(violations, file, workflow, "macos-metal-proof"); + add(violations, sameMembers(needs(metal), ["route", "packaged-proof"]), `${file} Metal proof must wait for package proof`); + add(violations, object(metal.with).use_packaged_cli_artifact === true, `${file} Metal proof must use the packaged CLI`); + const closeout = requireJob(violations, file, workflow, "closeout"); + requireStepRun(violations, file, closeout, "Require one coherent accepted proof", ["dev/codestory-next moved from proved head"]); + add(violations, !scalarStrings(workflow).some(value => value === "./.github/workflows/release.yml"), `${file} must not publish releases`); +} + +function validateRemainingWorkflows(workflows, violations) { + const autoFile = "auto-release.yml"; + const auto = workflows.get(autoFile); + if (!auto) { + violations.push(`${autoFile} must exist`); + } else { + add(violations, includesAll(at(auto, "on", "push", "branches"), ["main"]), `${autoFile} must run on main`); + add(violations, includesAll(at(auto, "on", "push", "paths"), ["package.json", "package-lock.json"]), `${autoFile} must observe policy dependency changes`); + const policy = requireJob(violations, autoFile, auto, "workflow-policy"); + requireStepRun(violations, autoFile, policy, "Install workflow policy dependencies", ["npm ci --ignore-scripts"]); + requireStepRun(violations, autoFile, policy, "Enforce workflow policy", ["node .github/scripts/check-workflow-policy.mjs"]); + const release = requireJob(violations, autoFile, auto, "release"); + add(violations, release.uses === "./.github/workflows/release.yml", `${autoFile} must call the release workflow`); + add(violations, sameMembers(needs(release), ["detect-version"]), `${autoFile} release must need version detection`); + add(violations, object(release.permissions).contents === "write", `${autoFile} release caller must grant contents write`); + add(violations, object(release.permissions).actions === "read", `${autoFile} release caller must grant actions read`); + } + + const evidenceFile = "release-candidate-evidence.yml"; + const evidence = workflows.get(evidenceFile); + if (!evidence) { + violations.push(`${evidenceFile} must exist`); + } else { + add(violations, trigger(evidence, "workflow_call") !== undefined, `${evidenceFile} must be reusable`); + add(violations, trigger(evidence, "workflow_dispatch") === undefined, `${evidenceFile} must be coordinator-only`); + const job = requireJob(violations, evidenceFile, evidence, "measure"); + add(violations, JSON.stringify(job["runs-on"]) === JSON.stringify(["self-hosted", "Linux", "ARM64", "codestory-release-evidence"]), `${evidenceFile} must use the protected evidence runner`); + add(violations, job.environment === "release-evidence", `${evidenceFile} must use the release-evidence environment`); + requireStepRun(violations, evidenceFile, job, "Produce full-sidecar repo evidence", ["--test-threads=1"]); + requireStepRun(violations, evidenceFile, job, "Download prior rejected evidence for approval re-evaluation", ["actions/runs/$SOURCE_RUN_ID", "actions/runs/$SOURCE_RUN_ID/artifacts"]); + } + + const metalFile = "macos-metal-proof.yml"; + const metal = workflows.get(metalFile); + if (!metal) { + violations.push(`${metalFile} must exist`); + } else { + add(violations, trigger(metal, "workflow_call") !== undefined && trigger(metal, "workflow_dispatch") !== undefined, `${metalFile} must support reusable and manual proof`); + const job = requireJob(violations, metalFile, metal, "packaged-metal-lifecycle"); + add(violations, JSON.stringify(job["runs-on"]) === JSON.stringify(["self-hosted", "macOS", "ARM64", "codestory-metal"]), `${metalFile} must use the protected Apple Silicon runner`); + add(violations, job.environment === "macos-metal-release", `${metalFile} must use the protected Metal environment`); + requireStepRun(violations, metalFile, job, "Capture host evidence", ["python3 --version", 'test "$macos_major" -ge 15']); + const lifecycle = namedStep(job, "Prove cold, warm, dead-endpoint, recovery, packet, and plugin lifecycle"); + requireStepRun(violations, metalFile, job, "Prove cold, warm, dead-endpoint, recovery, packet, and plugin lifecycle", ["--native-accelerator-lifecycle", "--managed-plugin-grounding-convergence"]); + add(violations, object(lifecycle?.env).CODESTORY_EMBED_DEVICE_PROVIDER === "metal", `${metalFile} lifecycle must require Metal`); + add(violations, object(lifecycle?.env).CODESTORY_EMBED_ALLOW_CPU === "0", `${metalFile} lifecycle must reject CPU fallback`); + requireStepRun(violations, metalFile, job, "Clean and assert proof-owned hardware state", ["--cleanup-proof-temp-root"]); + } + + const statsFile = "repo-scale-stats.yml"; + const stats = workflows.get(statsFile); + if (!stats) { + violations.push(`${statsFile} must exist`); + } else { + const job = requireJob(violations, statsFile, stats, "stats"); + requireStepRun(violations, statsFile, job, "Build the release CLI", ["cargo build --release --locked -p codestory-cli"]); + requireStepRun(violations, statsFile, job, "Run mandatory repo-scale stats once", ["cargo test --locked -p codestory-cli --test codestory_repo_e2e_stats -- --ignored --nocapture"]); + requireStepUses(violations, statsFile, job, "Upload repo-scale stats output", "actions/upload-artifact@v7.0.1"); + } + + const retrievalFile = "retrieval-sidecar-smoke.yml"; + const retrieval = workflows.get(retrievalFile); + if (!retrieval) { + violations.push(`${retrievalFile} must exist`); + } else { + const windows = requireJob(violations, retrievalFile, retrieval, "windows-manifest-missing"); + add(violations, windows.if === "github.event_name == 'workflow_dispatch'", `${retrievalFile} Windows proof must be workflow-dispatch only`); + add(violations, !scalarStrings(windows).some(value => value.includes("labels")), `${retrievalFile} Windows proof must not be label-triggered`); + } + + const guardFile = "main-branch-source-guard.yml"; + const guard = workflows.get(guardFile); + if (!guard) { + violations.push(`${guardFile} must exist`); + } else { + add(violations, includesAll(at(guard, "on", "pull_request", "branches"), ["main"]), `${guardFile} must guard main`); + const job = requireJob(violations, guardFile, guard, "enforce-source-branch"); + const step = namedStep(job, "Require dev/codestory-next source branch"); + add(violations, object(step?.env).HEAD_REPO !== undefined && object(step?.env).BASE_REPO !== undefined, `${guardFile} must compare source and base repository identity`); + add(violations, String(step?.run ?? "").includes("dev/codestory-next"), `${guardFile} must require the dev source branch`); + } +} + +export function validateWorkflows(workflows) { + const violations = []; + for (const [file, workflow] of workflows) { + violations.push(...basicWorkflowViolations(file, workflow)); + } + validateLockedSetupSurfaces(violations); + validateIssueWorkflows(workflows, violations); + validatePluginAndDraftWorkflows(workflows, violations); + validateReleaseCoordinator(workflows, violations); + validatePackagedProof(workflows, violations); + validatePostPublish(workflows, violations); + validatePackagedCoordinator(workflows, violations); + validateRemainingWorkflows(workflows, violations); + return violations; } -console.log("Workflow policy passed: third-party actions and saga issue-link guard are valid."); +function main() { + let workflows; + try { + workflows = loadWorkflows(); + } catch (error) { + console.error(`Workflow YAML parse failed: ${error.message}`); + process.exit(1); + } + const violations = validateWorkflows(workflows); + if (violations.length > 0) { + console.error(violations.join("\n")); + process.exit(1); + } + console.log("Workflow policy passed: parsed workflow structure satisfies repository contracts."); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + main(); +} diff --git a/.github/scripts/check-workflow-policy.test.mjs b/.github/scripts/check-workflow-policy.test.mjs new file mode 100644 index 00000000..45752da6 --- /dev/null +++ b/.github/scripts/check-workflow-policy.test.mjs @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + basicWorkflowViolations, + managedPluginViolations, + notaryStepViolations, + packagedPrSigningViolations, + parseWorkflow, +} from "./check-workflow-policy.mjs"; + +const fullSha = "0123456789abcdef0123456789abcdef01234567"; + +function managedJob() { + return { + strategy: { "fail-fast": false, matrix: { include: [{ os: "ubuntu-latest" }] } }, + steps: [ + { + name: "Prove managed plugin handoff", + run: [ + "python .github/scripts/check-packaged-agent-proof.py", + "--archive package.tar.gz", + "--managed-plugin-handoff", + ].join("\n"), + }, + ], + }; +} + +test("parser ignores YAML comments and harmless formatting", () => { + const block = parseWorkflow(` +on: + pull_request: +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: vendor/action@${fullSha} +# uses: vendor/action@main +`); + const flow = parseWorkflow(` +"on": { pull_request: null } +permissions: { contents: read } +jobs: { check: { runs-on: ubuntu-latest, steps: [ { uses: vendor/action@${fullSha} } ] } } +`); + assert.deepEqual(block, flow); +}); + +test("third-party action policy reads only parsed uses values", () => { + const valid = parseWorkflow(` +on: { workflow_dispatch: null } +jobs: + check: + steps: + - uses: vendor/action@${fullSha} +# uses: vendor/action@main +`); + assert.deepEqual(basicWorkflowViolations("fixture.yml", valid), []); + + const invalid = structuredClone(valid); + invalid.jobs.check.steps[0].uses = "vendor/action@main"; + assert.match(basicWorkflowViolations("fixture.yml", invalid).join("\n"), /full-length SHA/u); +}); + +test("Cargo lock policy reads executable step commands", () => { + const workflow = parseWorkflow(` +on: { workflow_dispatch: null } +jobs: + check: + steps: + - run: | + # cargo test --workspace + cargo test --workspace --locked +`); + assert.deepEqual(basicWorkflowViolations("fixture.yml", workflow), []); + + workflow.jobs.check.steps[0].run += "\ncargo check --workspace\n"; + assert.match(basicWorkflowViolations("fixture.yml", workflow).join("\n"), /must use --locked/u); +}); + +test("managed proof rejects structural bypasses and decoy commands", () => { + assert.deepEqual(managedPluginViolations(managedJob(), "--archive package.tar.gz"), []); + + const mutations = [ + job => { job.strategy["fail-fast"] = true; }, + job => { job.strategy.matrix.exclude = [{ os: "ubuntu-latest" }]; }, + job => { job.if = "always()"; }, + job => { job.steps[0]["continue-on-error"] = true; }, + job => { + job.steps[0].run = "python .github/scripts/check-packaged-agent-proof.py\n--archive package.tar.gz\n# --managed-plugin-handoff"; + job.steps.push({ name: "Decoy", run: "--managed-plugin-handoff" }); + }, + ]; + for (const mutate of mutations) { + const candidate = managedJob(); + mutate(candidate); + assert.notDeepEqual(managedPluginViolations(candidate, "--archive package.tar.gz"), []); + } +}); + +test("PR package proof cannot opt into signing credentials", () => { + const workflow = { jobs: { "packaged-proof": { with: { sign_macos: false } } } }; + assert.deepEqual(packagedPrSigningViolations(workflow), []); + + for (const mutate of [ + candidate => { candidate.jobs["packaged-proof"].with.sign_macos = true; }, + candidate => { candidate.jobs["packaged-proof"].secrets = "inherit"; }, + candidate => { candidate.jobs["packaged-proof"].environment = "macos-release-signing"; }, + candidate => { candidate.env = { APPLE_NOTARY_KEY_ID: "forbidden" }; }, + ]) { + const candidate = structuredClone(workflow); + mutate(candidate); + assert.notDeepEqual(packagedPrSigningViolations(candidate), []); + } +}); + +test("notarization must use explicit polling", () => { + assert.deepEqual(notaryStepViolations({ run: "xcrun notarytool submit bundle.zip \\\n --no-wait" }), []); + assert.match( + notaryStepViolations({ run: "xcrun notarytool submit bundle.zip \\\n --wait" }).join("\n"), + /poll explicitly/u, + ); +}); diff --git a/.github/scripts/extract-codestory-release-notes.mjs b/.github/scripts/extract-codestory-release-notes.mjs new file mode 100644 index 00000000..9805a6e1 --- /dev/null +++ b/.github/scripts/extract-codestory-release-notes.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; +const NUMERIC_IDENTIFIER = /^\d+$/u; +const STRICT_NUMERIC_IDENTIFIER = /^(?:0|[1-9]\d*)$/u; + +function isStrictSemver(version) { + if (!VERSION_PATTERN.test(version)) return false; + const withoutBuild = version.split("+", 1)[0]; + const prereleaseStart = withoutBuild.indexOf("-"); + if (prereleaseStart < 0) return true; + return withoutBuild + .slice(prereleaseStart + 1) + .split(".") + .every(identifier => + !NUMERIC_IDENTIFIER.test(identifier) || STRICT_NUMERIC_IDENTIFIER.test(identifier)); +} + +export function extractReleaseNotes(changelog, version) { + if (!isStrictSemver(version)) { + throw new Error(`release version must be strict semver, got ${JSON.stringify(version)}`); + } + + const lines = changelog.split(/\r?\n/u); + const heading = `## ${version}`; + const matches = lines + .map((line, index) => line.trimEnd() === heading ? index : -1) + .filter(index => index >= 0); + + if (matches.length === 0) { + throw new Error(`CHANGELOG.md is missing the exact release heading ${heading}`); + } + if (matches.length > 1) { + throw new Error(`CHANGELOG.md contains duplicate release headings for ${heading}`); + } + + const start = matches[0] + 1; + const relativeEnd = lines.slice(start).findIndex(line => /^##(?:\s|$)/u.test(line)); + const end = relativeEnd < 0 ? lines.length : start + relativeEnd; + const section = lines.slice(start, end).join("\n").trim(); + if (!section) { + throw new Error(`CHANGELOG.md release heading ${heading} has no content`); + } + return `${section}\n`; +} + +function parseArgs(argv) { + const args = [...argv]; + const values = new Map(); + while (args.length > 0) { + const flag = args.shift(); + if (!flag?.startsWith("--")) { + throw new Error(`unexpected argument: ${flag ?? ""}`); + } + const value = args.shift(); + if (!value || value.startsWith("--")) { + throw new Error(`${flag} requires a value`); + } + values.set(flag, value); + } + return { + changelog: values.get("--changelog") ?? "CHANGELOG.md", + output: values.get("--output") ?? "", + version: values.get("--version") ?? "", + }; +} + +function main(argv) { + const options = parseArgs(argv); + const notes = extractReleaseNotes(fs.readFileSync(options.changelog, "utf8"), options.version); + if (options.output) { + fs.mkdirSync(path.dirname(options.output), { recursive: true }); + fs.writeFileSync(options.output, notes, "utf8"); + } else { + process.stdout.write(notes); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} diff --git a/.github/scripts/package-codestory-release.py b/.github/scripts/package-codestory-release.py index ba0af328..8d0ce598 100644 --- a/.github/scripts/package-codestory-release.py +++ b/.github/scripts/package-codestory-release.py @@ -118,8 +118,8 @@ def package_release( checksum = sha256_file(archive_path) checksum_line = f"{checksum} {archive_path.name}\n" checksum_path = out_dir / f"{archive_path.name}.sha256" - checksum_path.write_text(checksum_line, encoding="utf-8", newline="\n") - (out_dir / "SHA256SUMS.txt").write_text(checksum_line, encoding="utf-8", newline="\n") + checksum_path.write_bytes(checksum_line.encode("utf-8")) + (out_dir / "SHA256SUMS.txt").write_bytes(checksum_line.encode("utf-8")) return archive_path diff --git a/.github/scripts/route-ci-proof.mjs b/.github/scripts/route-ci-proof.mjs new file mode 100644 index 00000000..33a36fd2 --- /dev/null +++ b/.github/scripts/route-ci-proof.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; + +const scopeRank = new Map([ + ["none", 0], + ["macos", 1], + ["full", 2], +]); +const coordinatorScopes = new Set(["windows", ...scopeRank.keys()]); + +const macosSurfaces = [ + /^\.github\/workflows\/macos-/u, + /^backend\/.*(?:darwin|macos|metal)/iu, + /^scripts\/setup-retrieval-env\./u, + /^plugins\/codestory\/.*(?:darwin|macos|metal)/iu, +]; + +const crossPlatformRuntimeSurfaces = [ + /^(?:Cargo\.toml|Cargo\.lock)$/u, + /^crates\/[^/]+\/Cargo\.toml$/u, + /^crates\/codestory-cli\/src\/readiness_broker\//u, + /^crates\/codestory-retrieval\/src\/(?:config|index|inventory|lib|query|sidecar)\.rs$/u, + /^scripts\/install-codestory\.ps1$/u, + /^scripts\/codex-worktree-setup\.(?:mjs|ps1|sh)$/u, + /^scripts\/tests\/codex-worktree-setup\.test\.mjs$/u, +]; + +const proofNeutralSurfaces = [ + /^CHANGELOG\.md$/u, + /^docs\//u, + /^\.github\/workflows\/retrieval-sidecar-smoke\.yml$/u, + /^crates\/codestory-runtime\/tests\/retrieval_generalization_guard\.rs$/u, + /^scripts\/(?:codestory-agent-ab-benchmark|codestory-evidence-provenance|codestory-release-evidence-gate|lint-retrieval-generalization)\.mjs$/u, +]; + +function cleanPaths(paths) { + return [...new Set(paths.map(value => value.trim().replaceAll("\\", "/")).filter(Boolean))]; +} + +export function classifyProofScope(paths) { + const changed = cleanPaths(paths); + if (changed.length === 0) return "none"; + if (changed.some(file => crossPlatformRuntimeSurfaces.some(pattern => pattern.test(file)))) { + return "full"; + } + if (changed.some(file => macosSurfaces.some(pattern => pattern.test(file)))) { + return "macos"; + } + if (changed.every(file => proofNeutralSurfaces.some(pattern => pattern.test(file)))) { + return "none"; + } + return "full"; +} + +export function selectProofScope(paths, requested = "auto") { + const inferred = classifyProofScope(paths); + if (requested === "auto" || requested === "") return inferred; + if (!coordinatorScopes.has(requested)) { + throw new Error(`unsupported proof scope: ${requested}`); + } + if (requested === "windows") return requested; + return scopeRank.get(requested) > scopeRank.get(inferred) ? requested : inferred; +} + +function selfTest() { + const fixtures = [ + { + name: "script and guard tests do not package", + expected: "none", + paths: [ + ".github/workflows/retrieval-sidecar-smoke.yml", + "crates/codestory-runtime/tests/retrieval_generalization_guard.rs", + "scripts/lint-retrieval-generalization.mjs", + "docs/testing/retrieval-architecture.md", + "CHANGELOG.md", + ], + }, + { + name: "Mac lifecycle changes stay on Mac", + expected: "macos", + paths: [ + ".github/scripts/check-packaged-agent-proof.py", + ".github/workflows/macos-metal-proof.yml", + "crates/codestory-cli/src/ready_repair_status.rs", + "crates/codestory-cli/src/stdio_transport.rs", + "crates/codestory-retrieval/src/embeddings.rs", + ], + }, + { + name: "runtime identity changes use every platform", + expected: "full", + paths: [ + "Cargo.lock", + "crates/codestory-cli/src/readiness_broker/native_lease.rs", + "crates/codestory-retrieval/src/inventory.rs", + "crates/codestory-retrieval/src/sidecar.rs", + ], + }, + { + name: "shared worktree setup changes use every platform", + expected: "full", + paths: [ + "scripts/codex-worktree-setup.mjs", + "scripts/tests/codex-worktree-setup.test.mjs", + ], + }, + ]; + for (const fixture of fixtures) { + const actual = classifyProofScope(fixture.paths); + if (actual !== fixture.expected) { + throw new Error(`${fixture.name}: expected ${fixture.expected}, got ${actual}`); + } + } + if (selectProofScope(fixtures[0].paths, "macos") !== "macos") { + throw new Error("explicit promotion must be able to widen an inferred scope"); + } + if (selectProofScope(fixtures[2].paths, "macos") !== "full") { + throw new Error("explicit promotion must not narrow an inferred scope"); + } + if (selectProofScope(fixtures[2].paths, "windows") !== "windows") { + throw new Error("coordinator Windows proof must select only Windows x64 packaging"); + } +} + +function main(argv) { + const args = [...argv]; + if (args.includes("--self-test")) { + selfTest(); + console.log("CI proof routing fixtures passed."); + return; + } + const requestedIndex = args.indexOf("--requested"); + const requested = requestedIndex >= 0 ? args.splice(requestedIndex, 2)[1] : "auto"; + const stdin = args.includes("--stdin") ? fs.readFileSync(0, "utf8").split(/\r?\n/u) : []; + const paths = args.filter(arg => arg !== "--stdin"); + console.log(selectProofScope([...stdin, ...paths], requested)); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 7d3bd988..940f95d8 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -13,6 +13,8 @@ on: - .github/workflows/auto-release.yml - .github/workflows/release.yml - .github/scripts/** + - package.json + - package-lock.json - plugins/codestory/** permissions: @@ -31,7 +33,10 @@ jobs: - name: Checkout uses: actions/checkout@v5 - - name: Enforce third-party action pinning + - name: Install workflow policy dependencies + run: npm ci --ignore-scripts + + - name: Enforce workflow policy run: node .github/scripts/check-workflow-policy.mjs detect-version: @@ -69,6 +74,7 @@ jobs: needs: detect-version if: needs.detect-version.outputs.should_release == 'true' permissions: + actions: read contents: write uses: ./.github/workflows/release.yml with: diff --git a/.github/workflows/macos-metal-proof.yml b/.github/workflows/macos-metal-proof.yml new file mode 100644 index 00000000..08c79fb1 --- /dev/null +++ b/.github/workflows/macos-metal-proof.yml @@ -0,0 +1,197 @@ +name: macOS Apple Silicon Metal proof + +on: + workflow_call: + inputs: + version: + description: CodeStory version to prove. + required: true + type: string + ref: + description: Git ref to check out. Defaults to the caller SHA. + required: false + type: string + proof_key: + description: Stable PR, integration, or release identity for cancellation. + required: false + default: "" + type: string + use_packaged_cli_artifact: + description: Download the macOS arm64 package artifact produced by packaged-platform-proof. + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + version: + description: CodeStory version to prove. + required: true + type: string + ref: + description: Git ref to check out. Defaults to the current SHA. + required: false + type: string + proof_key: + description: Stable proof identity for cancellation. + required: false + type: string + +permissions: + contents: read + +concurrency: + group: macos-metal-proof-${{ inputs.proof_key || inputs.ref || github.ref }} + cancel-in-progress: true + +jobs: + packaged-metal-lifecycle: + name: Packaged Apple Silicon Metal lifecycle + runs-on: [self-hosted, macOS, ARM64, codestory-metal] + # Configure this environment with release protection rules and restrict the + # matching self-hosted runner/runner group to this evidence workflow. + environment: macos-metal-release + timeout-minutes: 120 + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref || github.sha }} + + - name: Capture host evidence + shell: bash + run: | + set -euo pipefail + mkdir -p target/macos-metal-proof + { + sw_vers + uname -a + uname -m + sysctl -n machdep.cpu.brand_string + xcode-select -p + node --version + python3 --version + docker version + } > target/macos-metal-proof/host.txt 2>&1 + test "$(uname -m)" = arm64 + macos_major="$(sw_vers -productVersion | cut -d. -f1)" + test "$macos_major" -ge 15 + docker info >/dev/null + + - name: Install pinned Rust + if: ${{ !inputs.use_packaged_cli_artifact }} + shell: bash + run: | + rustup toolchain install 1.95.0 --profile minimal + rustup default 1.95.0 + + - name: Build and package native CLI + if: ${{ !inputs.use_packaged_cli_artifact }} + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + version="${VERSION#v}" + cargo build --release -p codestory-cli --locked + python3 .github/scripts/package-codestory-release.py \ + --version "$version" \ + --target macos-arm64 \ + --binary target/release/codestory-cli \ + --out-dir target/release-dist + + - name: Download packaged CLI artifact + if: inputs.use_packaged_cli_artifact + uses: actions/download-artifact@v8.0.1 + with: + name: codestory-cli-macos-arm64 + path: target/release-dist + + - name: Verify retrieval setup contracts and fetch model + shell: bash + env: + CODESTORY_EMBED_MODEL_DIR: ${{ runner.temp }}/codestory-metal-models + run: | + set -euo pipefail + node scripts/setup-retrieval-env.mjs --self-test + node scripts/setup-retrieval-env.mjs --fetch-embed-model --fetch-only + + - name: Clean marker-owned state from an interrupted earlier hardware attempt + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + index=0 + failed=0 + for root in "$RUNNER_TEMP"/codestory-metal-proof-owned-*; do + marker="$root/.codestory-macos-metal-proof-owner.json" + archives=("$root"/codestory-cli-v*-macos-arm64.tar.gz) + if [ ! -f "$marker" ] || [ "${#archives[@]}" -ne 1 ]; then + continue + fi + if ! python3 .github/scripts/check-packaged-agent-proof.py \ + --project "${{ github.workspace }}" \ + --cleanup-proof-temp-root "$root" \ + --out-dir "target/macos-metal-proof/prior-attempt-cleanup-$index" + then + failed=1 + fi + index=$((index + 1)) + done + exit "$failed" + + - name: Prove cold, warm, dead-endpoint, recovery, packet, and plugin lifecycle + shell: bash + env: + VERSION: ${{ inputs.version }} + CODESTORY_EMBED_MODEL_DIR: ${{ runner.temp }}/codestory-metal-models + CODESTORY_EMBED_DEVICE_PROVIDER: metal + CODESTORY_EMBED_ALLOW_CPU: "0" + CODESTORY_PROOF_TEMP_ROOT: ${{ runner.temp }}/codestory-metal-proof-owned-${{ github.run_id }}-${{ github.run_attempt }} + run: | + set -euo pipefail + version="${VERSION#v}" + mkdir -p "$CODESTORY_PROOF_TEMP_ROOT/tmp with spaces ü" + cp "target/release-dist/codestory-cli-v${version}-macos-arm64.tar.gz" \ + "$CODESTORY_PROOF_TEMP_ROOT/" + export TMPDIR="$CODESTORY_PROOF_TEMP_ROOT/tmp with spaces ü" + python3 .github/scripts/check-packaged-agent-proof.py \ + --archive "target/release-dist/codestory-cli-v${version}-macos-arm64.tar.gz" \ + --checksum-file "target/release-dist/codestory-cli-v${version}-macos-arm64.tar.gz.sha256" \ + --expected-version "$version" \ + --project "${{ github.workspace }}" \ + --plugin-root plugins/codestory \ + --native-accelerator-lifecycle \ + --managed-plugin-grounding-convergence \ + --timeout-secs 1800 \ + --out-dir target/macos-metal-proof/packaged-agent + + - name: Clean and assert proof-owned hardware state + if: always() + shell: bash + env: + VERSION: ${{ inputs.version }} + CODESTORY_PROOF_TEMP_ROOT: ${{ runner.temp }}/codestory-metal-proof-owned-${{ github.run_id }}-${{ github.run_attempt }} + run: | + set -euo pipefail + version="${VERSION#v}" + archive="$CODESTORY_PROOF_TEMP_ROOT/codestory-cli-v${version}-macos-arm64.tar.gz" + marker="$CODESTORY_PROOF_TEMP_ROOT/.codestory-macos-metal-proof-owner.json" + if [ ! -f "$marker" ]; then + mkdir -p target/macos-metal-proof/final-cleanup + printf '%s\n' '{"status":"not_started","reason":"proof ownership marker was never written"}' \ + > target/macos-metal-proof/final-cleanup/proof-owned-cleanup.json + exit 0 + fi + test -f "$archive" + python3 .github/scripts/check-packaged-agent-proof.py \ + --project "${{ github.workspace }}" \ + --cleanup-proof-temp-root "$CODESTORY_PROOF_TEMP_ROOT" \ + --out-dir target/macos-metal-proof/final-cleanup + + - name: Upload Metal proof artifacts + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: macos-arm64-metal-proof-${{ inputs.version }} + path: target/macos-metal-proof + if-no-files-found: error diff --git a/.github/workflows/packaged-platform-pr.yml b/.github/workflows/packaged-platform-pr.yml index ede2b438..12fc39c7 100644 --- a/.github/workflows/packaged-platform-pr.yml +++ b/.github/workflows/packaged-platform-pr.yml @@ -1,36 +1,347 @@ -name: Packaged platform acceptance +name: Platform and integration proof on: pull_request: - paths: - - .github/scripts/check-packaged-agent-proof.py - - .github/scripts/package-codestory-release.py - - .github/workflows/packaged-platform-pr.yml - - .github/workflows/packaged-platform-proof.yml - - .github/workflows/post-publish-release-smoke.yml - - .github/workflows/release.yml - - plugins/codestory/** - - scripts/install-codestory.ps1 + types: [labeled, synchronize] workflow_dispatch: + inputs: + mode: + description: Prove an accepted PR head, run release evidence, or prove current dev. + required: true + default: platform + type: choice + options: [platform, release-evidence, integration] + pr_number: + description: Same-repository pull request for platform mode. + required: false + type: string + expected_head_sha: + description: Exact accepted head SHA. + required: true + type: string + scope: + description: Auto-detect proof scope or select a coordinator package scope. + required: true + default: auto + type: choice + options: [auto, windows, macos, full] + source_run_id: + description: Optional prior rejected release-evidence run to re-evaluate. + required: false + type: string permissions: + actions: read contents: read + pull-requests: read + +concurrency: + group: proof-${{ inputs.mode || 'platform' }}-${{ inputs.pr_number || github.event.pull_request.number || 'dev' }}-${{ github.event.action == 'labeled' && github.event.label.name != 'platform-proof' && github.event.label.name || 'candidate' }} + cancel-in-progress: true jobs: - prepare: + route: + if: github.event_name != 'pull_request' || (github.event.action == 'labeled' && github.event.label.name == 'platform-proof') runs-on: ubuntu-latest outputs: + head_sha: ${{ steps.resolve.outputs.head_sha }} + mode: ${{ steps.resolve.outputs.mode }} + proof_key: ${{ steps.resolve.outputs.proof_key }} + scope: ${{ steps.scope.outputs.scope }} version: ${{ steps.version.outputs.version }} steps: + - name: Resolve trusted exact head + id: resolve + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_HEAD_SHA: ${{ inputs.expected_head_sha }} + INPUT_MODE: ${{ inputs.mode }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + run: | + set -euo pipefail + mode="${INPUT_MODE:-platform}" + if [ "$mode" = "integration" ]; then + test "$GITHUB_EVENT_NAME" = "workflow_dispatch" + test -n "$INPUT_HEAD_SHA" + dev_head="$(gh api "repos/$GITHUB_REPOSITORY/branches/dev/codestory-next" --jq '.commit.sha')" + test "$INPUT_HEAD_SHA" = "$dev_head" || { + echo "::error::Integration proof requires current dev head $dev_head, not $INPUT_HEAD_SHA." + exit 1 + } + { + echo "head_sha=$dev_head" + echo "base_sha=" + echo "mode=integration" + echo "proof_key=integration-$dev_head" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + test "$mode" = "platform" || test "$mode" = "release-evidence" + pr_number="${EVENT_PR_NUMBER:-$INPUT_PR_NUMBER}" + expected_head="${EVENT_HEAD_SHA:-$INPUT_HEAD_SHA}" + test -n "$pr_number" && test -n "$expected_head" + pr="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr_number")" + head_repo="$(jq -r '.head.repo.full_name' <<<"$pr")" + current_head="$(jq -r '.head.sha' <<<"$pr")" + base_sha="$(jq -r '.base.sha' <<<"$pr")" + base_ref="$(jq -r '.base.ref' <<<"$pr")" + test "$head_repo" = "$GITHUB_REPOSITORY" || { + echo "::error::Platform proof does not execute fork pull requests." + exit 1 + } + if [ -n "$EVENT_HEAD_REPO" ]; then + test "$EVENT_HEAD_REPO" = "$GITHUB_REPOSITORY" || exit 1 + fi + test "$current_head" = "$expected_head" || { + echo "::error::PR #$pr_number moved from promoted head $expected_head to $current_head." + exit 1 + } + if [ "$mode" = "release-evidence" ]; then + test "$GITHUB_EVENT_NAME" = "workflow_dispatch" + test "$base_ref" = "dev/codestory-next" || { + echo "::error::Release evidence requires a PR into dev/codestory-next, not $base_ref." + exit 1 + } + jq -e 'any(.labels[]; .name == "review-accepted")' <<<"$pr" >/dev/null || { + echo "::error::PR #$pr_number does not carry the review-accepted label." + exit 1 + } + fi + { + echo "head_sha=$current_head" + echo "base_sha=$base_sha" + echo "mode=$mode" + echo "proof_key=$mode-pr-$pr_number-$current_head" + } >> "$GITHUB_OUTPUT" + + - name: Require successful exact-head source proof + if: steps.resolve.outputs.mode != 'integration' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ steps.resolve.outputs.head_sha }} + run: | + set -euo pipefail + accepted=false + while IFS= read -r run_id; do + if gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/jobs?per_page=100" \ + --jq '.jobs[] | select(.name == "full-source-gate" and .conclusion == "success") | .id' \ + | grep -q . + then + accepted=true + echo "Accepted exact-head source proof run $run_id for $HEAD_SHA." + break + fi + done < <( + gh api --paginate \ + "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$HEAD_SHA&status=completed&per_page=100" \ + | jq -r --arg repo "$GITHUB_REPOSITORY" \ + '.workflow_runs[] | select(.path == ".github/workflows/source-proof.yml" and .head_repository.full_name == $repo and (.event == "pull_request" or .event == "workflow_dispatch") and .conclusion == "success") | .id' + ) + test "$accepted" = true || { + echo "::error::No successful full-source-gate job exists for exact head $HEAD_SHA." + exit 1 + } + - uses: actions/checkout@v5 - - id: version + with: + ref: ${{ steps.resolve.outputs.head_sha }} + fetch-depth: 0 + + - name: Select change-aware proof scope + id: scope + shell: bash + env: + BASE_SHA: ${{ steps.resolve.outputs.base_sha }} + HEAD_SHA: ${{ steps.resolve.outputs.head_sha }} + REQUESTED_SCOPE: ${{ inputs.scope || 'auto' }} + run: | + set -euo pipefail + if [ "${{ steps.resolve.outputs.mode }}" = "integration" ]; then + scope=full + elif [ "${{ steps.resolve.outputs.mode }}" = "release-evidence" ]; then + scope=none + else + scope="$(git diff --name-only "$BASE_SHA...$HEAD_SHA" | node .github/scripts/route-ci-proof.mjs --stdin --requested "$REQUESTED_SCOPE")" + fi + echo "scope=$scope" >> "$GITHUB_OUTPUT" + echo "Selected $scope platform proof." + + - name: Verify package version surfaces + if: steps.resolve.outputs.mode != 'release-evidence' + id: version + shell: bash run: | version="$(python -c "import tomllib; print(tomllib.load(open('crates/codestory-cli/Cargo.toml', 'rb'))['package']['version'])")" python .github/scripts/check-codestory-release.py --version "$version" echo "version=$version" >> "$GITHUB_OUTPUT" - native-packaged-proof: - needs: prepare + macos-source: + if: needs.route.outputs.scope == 'macos' + needs: route + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + target: aarch64-apple-darwin + - os: macos-15-intel + target: x86_64-apple-darwin + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.route.outputs.head_sha }} + - name: Install Rust stable + run: | + rustup toolchain install stable --profile minimal + rustup default stable + - name: Capture Rust cache identity + id: rust-cache-key + shell: bash + run: | + echo "version=$(rustc -Vv | sed -n 's/^release: //p')" >> "$GITHUB_OUTPUT" + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-macos-source-${{ steps.rust-cache-key.outputs.version }}-${{ matrix.target }}-workspace-default-features-${{ hashFiles('Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-macos-source-${{ steps.rust-cache-key.outputs.version }}-${{ matrix.target }}-workspace-default-features- + - run: cargo check --workspace --locked + - run: node scripts/setup-retrieval-env.mjs --self-test + - run: node --test scripts/tests/codex-worktree-setup.test.mjs + - run: test -x plugins/codestory/skills/codestory-grounding/scripts/setup.sh + + repo-scale-stats: + if: needs.route.outputs.mode != 'release-evidence' + needs: route + uses: ./.github/workflows/repo-scale-stats.yml + with: + ref: ${{ needs.route.outputs.head_sha }} + proof_key: ${{ needs.route.outputs.proof_key }} + + release-evidence: + if: needs.route.outputs.mode == 'release-evidence' + needs: route + uses: ./.github/workflows/release-candidate-evidence.yml + with: + ref: ${{ needs.route.outputs.head_sha }} + proof_key: ${{ needs.route.outputs.proof_key }} + profile: codestory-release-evidence-linux-arm64-v1 + drill_manifest: /srv/codestory-release-evidence/drills/real-repo-drill-cases.json + embedding_model_dir: /srv/codestory-release-evidence/models + source_run_id: ${{ inputs.source_run_id }} + + source-proof: + if: needs.route.outputs.mode == 'integration' + needs: route + uses: ./.github/workflows/source-proof.yml + with: + ref: ${{ needs.route.outputs.head_sha }} + proof_key: ${{ needs.route.outputs.proof_key }} + + packaged-proof: + if: >- + always() && + needs.route.result == 'success' && + needs.route.outputs.scope != 'none' && + (needs.macos-source.result == 'success' || needs.macos-source.result == 'skipped') && + (needs.source-proof.result == 'success' || (needs.route.outputs.mode == 'platform' && needs.source-proof.result == 'skipped')) + needs: + - route + - macos-source + - source-proof uses: ./.github/workflows/packaged-platform-proof.yml with: - version: ${{ needs.prepare.outputs.version }} + version: ${{ needs.route.outputs.version }} + ref: ${{ needs.route.outputs.head_sha }} + scope: ${{ needs.route.outputs.scope }} + proof_key: ${{ needs.route.outputs.proof_key }} + sign_macos: false + + macos-metal-proof: + if: >- + always() && + needs.route.result == 'success' && + needs.packaged-proof.result == 'success' && + (needs.route.outputs.scope == 'macos' || needs.route.outputs.scope == 'full') + needs: + - route + - packaged-proof + uses: ./.github/workflows/macos-metal-proof.yml + with: + version: ${{ needs.route.outputs.version }} + ref: ${{ needs.route.outputs.head_sha }} + proof_key: ${{ needs.route.outputs.proof_key }} + use_packaged_cli_artifact: true + + closeout: + if: always() && needs.route.result != 'skipped' && needs.route.outputs.mode != 'release-evidence' + needs: + - route + - macos-source + - source-proof + - repo-scale-stats + - packaged-proof + - macos-metal-proof + runs-on: ubuntu-latest + steps: + - name: Require one coherent accepted proof + shell: bash + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ needs.route.outputs.head_sha }} + MODE: ${{ needs.route.outputs.mode }} + SCOPE: ${{ needs.route.outputs.scope }} + ROUTE_RESULT: ${{ needs.route.result }} + MACOS_SOURCE_RESULT: ${{ needs.macos-source.result }} + SOURCE_RESULT: ${{ needs.source-proof.result }} + STATS_RESULT: ${{ needs.repo-scale-stats.result }} + PACKAGE_RESULT: ${{ needs.packaged-proof.result }} + METAL_RESULT: ${{ needs.macos-metal-proof.result }} + run: | + set -euo pipefail + require_result() { + test "$1" = "$2" || { + echo "::error::$3 finished $1; expected $2." + exit 1 + } + } + require_result "$ROUTE_RESULT" success routing + require_result "$STATS_RESULT" success repo-scale-stats + if [ "$MODE" = integration ]; then + require_result "$SOURCE_RESULT" success source-proof + else + require_result "$SOURCE_RESULT" skipped source-proof + fi + if [ "$SCOPE" = macos ]; then + require_result "$MACOS_SOURCE_RESULT" success macos-source + else + require_result "$MACOS_SOURCE_RESULT" skipped macos-source + fi + if [ "$SCOPE" = none ]; then + require_result "$PACKAGE_RESULT" skipped packaged-proof + require_result "$METAL_RESULT" skipped macos-metal-proof + else + require_result "$PACKAGE_RESULT" success packaged-proof + if [ "$SCOPE" = windows ]; then + require_result "$METAL_RESULT" skipped macos-metal-proof + else + require_result "$METAL_RESULT" success macos-metal-proof + fi + fi + if [ "$MODE" = integration ]; then + current_dev="$(gh api "repos/$GITHUB_REPOSITORY/branches/dev/codestory-next" --jq '.commit.sha')" + test "$HEAD_SHA" = "$current_dev" || { + echo "::error::dev/codestory-next moved from proved head $HEAD_SHA to $current_dev." + exit 1 + } + fi diff --git a/.github/workflows/packaged-platform-proof.yml b/.github/workflows/packaged-platform-proof.yml index d8554dad..f87e082e 100644 --- a/.github/workflows/packaged-platform-proof.yml +++ b/.github/workflows/packaged-platform-proof.yml @@ -7,47 +7,76 @@ on: description: Release version from crates/codestory-cli/Cargo.toml. required: true type: string + ref: + description: Exact source SHA to package. + required: false + default: "" + type: string + scope: + description: Native package matrix scope (windows, macos, or full). + required: false + default: full + type: string + proof_key: + description: Stable PR or integration identity for concurrency cancellation. + required: false + default: "" + type: string + sign_macos: + description: Require Developer ID signing and Apple notarization for macOS assets. + required: false + default: false + type: boolean + secrets: + APPLE_DEVELOPER_ID_P12_BASE64: + required: false + APPLE_DEVELOPER_ID_P12_PASSWORD: + required: false + APPLE_SIGNING_IDENTITY: + required: false + APPLE_NOTARY_KEY_P8_BASE64: + required: false + APPLE_NOTARY_KEY_ID: + required: false + APPLE_NOTARY_ISSUER_ID: + required: false permissions: contents: read +concurrency: + group: packaged-platform-proof-${{ inputs.proof_key || github.event.pull_request.number || github.ref }} + cancel-in-progress: true + env: RELEASE_RUST_TOOLCHAIN: "1.95.0" + LINUX_GLIBC_BUILD_IMAGE: "rust:1.95.0-bullseye@sha256:28afaeb8445f2a2e7d878bd34ed39ba02bb517efb29986188cbd59b7cf4f2fdf" + LINUX_GLIBC_BASELINE_IMAGE: "ubuntu:20.04@sha256:8feb4d8ca5354def3d8fce243717141ce31e2c428701f6682bd2fafe15388214" + APPLE_DEVELOPER_TEAM_ID: "PKUJNR8D6F" jobs: build: name: Build ${{ matrix.asset_target }} runs-on: ${{ matrix.os }} - timeout-minutes: 60 + # The six APPLE_* credentials are an environment-scoped release contract; + # repository-only secrets are not the supported signing configuration. + environment: ${{ inputs.sign_macos && startsWith(matrix.asset_target, 'macos-') && 'macos-release-signing' || null }} + timeout-minutes: ${{ inputs.sign_macos && startsWith(matrix.asset_target, 'macos-') && 90 || 60 }} strategy: fail-fast: false - matrix: - include: - - os: ubuntu-latest - rust_target: x86_64-unknown-linux-gnu - asset_target: linux-x64 - exe_suffix: "" - - os: ubuntu-24.04-arm - rust_target: aarch64-unknown-linux-gnu - asset_target: linux-arm64 - exe_suffix: "" - - os: windows-latest - rust_target: x86_64-pc-windows-msvc - asset_target: windows-x64 - exe_suffix: ".exe" - - os: windows-11-arm - rust_target: aarch64-pc-windows-msvc - asset_target: windows-arm64 - exe_suffix: ".exe" - # ponytail: ort-sys has no x86_64-apple-darwin prebuilt; add macos-x64 - # when the release path links a custom ONNX Runtime build. - - os: macos-15 - rust_target: aarch64-apple-darwin - asset_target: macos-arm64 - exe_suffix: "" + # GitHub applies matrix exclusions before includes, so an include-only + # matrix must select the complete row set rather than trying to exclude + # rows after the fact. + matrix: ${{ fromJSON(inputs.scope == 'windows' && '{"include":[{"os":"windows-latest","rust_target":"x86_64-pc-windows-msvc","asset_target":"windows-x64","exe_suffix":".exe","extension":"zip"}]}' || inputs.scope == 'macos' && '{"include":[{"os":"macos-15-intel","rust_target":"x86_64-apple-darwin","asset_target":"macos-x64","exe_suffix":"","extension":"tar.gz"},{"os":"macos-15","rust_target":"aarch64-apple-darwin","asset_target":"macos-arm64","exe_suffix":"","extension":"tar.gz"}]}' || '{"include":[{"os":"ubuntu-latest","rust_target":"x86_64-unknown-linux-gnu","asset_target":"linux-x64","exe_suffix":"","extension":"tar.gz"},{"os":"ubuntu-24.04-arm","rust_target":"aarch64-unknown-linux-gnu","asset_target":"linux-arm64","exe_suffix":"","extension":"tar.gz"},{"os":"windows-latest","rust_target":"x86_64-pc-windows-msvc","asset_target":"windows-x64","exe_suffix":".exe","extension":"zip"},{"os":"windows-11-arm","rust_target":"aarch64-pc-windows-msvc","asset_target":"windows-arm64","exe_suffix":".exe","extension":"zip"},{"os":"macos-15-intel","rust_target":"x86_64-apple-darwin","asset_target":"macos-x64","exe_suffix":"","extension":"tar.gz"},{"os":"macos-15","rust_target":"aarch64-apple-darwin","asset_target":"macos-arm64","exe_suffix":"","extension":"tar.gz"}]}') }} steps: - name: Checkout uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref || github.sha }} + + - name: Check shared worktree setup and host adapter + if: matrix.asset_target == 'linux-x64' || matrix.asset_target == 'windows-x64' + run: node --test scripts/tests/codex-worktree-setup.test.mjs - name: Install pinned Rust run: | @@ -70,12 +99,214 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-release-${{ env.RELEASE_RUST_TOOLCHAIN }}-${{ steps.rust-cache-key.outputs.version }}-${{ matrix.rust_target }}-${{ hashFiles('Cargo.lock') }} + key: ${{ runner.os }}-release-${{ env.RELEASE_RUST_TOOLCHAIN }}-${{ steps.rust-cache-key.outputs.version }}-${{ matrix.rust_target }}-codestory-cli-default-features-${{ hashFiles('Cargo.lock') }} restore-keys: | - ${{ runner.os }}-release-${{ env.RELEASE_RUST_TOOLCHAIN }}-${{ steps.rust-cache-key.outputs.version }}-${{ matrix.rust_target }}- + ${{ runner.os }}-release-${{ env.RELEASE_RUST_TOOLCHAIN }}-${{ steps.rust-cache-key.outputs.version }}-${{ matrix.rust_target }}-codestory-cli-default-features- - name: Build codestory-cli - run: cargo build --release -p codestory-cli --target "${{ matrix.rust_target }}" + if: matrix.asset_target != 'linux-x64' + run: cargo build --release --locked -p codestory-cli --target "${{ matrix.rust_target }}" + + - name: Build Linux x64 at the glibc 2.31 baseline + if: matrix.asset_target == 'linux-x64' + run: | + docker run --rm --platform linux/amd64 \ + --user "$(id -u):$(id -g)" \ + --env CARGO_HOME=/cargo \ + --env CARGO_TARGET_DIR=/workspace/target/glibc-2.31 \ + --volume "$HOME/.cargo:/cargo" \ + --volume "$PWD:/workspace" \ + --workdir /workspace \ + "${{ env.LINUX_GLIBC_BUILD_IMAGE }}" \ + cargo build --release --locked -p codestory-cli --target "${{ matrix.rust_target }}" + mkdir -p "target/${{ matrix.rust_target }}/release" + cp "target/glibc-2.31/${{ matrix.rust_target }}/release/codestory-cli" \ + "target/${{ matrix.rust_target }}/release/codestory-cli" + + - name: Sign and notarize macOS CLI + if: runner.os == 'macOS' && inputs.sign_macos + shell: bash + env: + APPLE_DEVELOPER_ID_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} + APPLE_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_NOTARY_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }} + APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + run: | + set -euo pipefail + umask 077 + for name in \ + APPLE_DEVELOPER_ID_P12_BASE64 \ + APPLE_DEVELOPER_ID_P12_PASSWORD \ + APPLE_SIGNING_IDENTITY \ + APPLE_NOTARY_KEY_P8_BASE64 \ + APPLE_NOTARY_KEY_ID \ + APPLE_NOTARY_ISSUER_ID + do + if [ -z "${!name:-}" ]; then + echo "::error::Missing required protected macOS release secret: $name" + exit 1 + fi + done + + bin="target/${{ matrix.rust_target }}/release/codestory-cli" + proof_dir="target/notarization-proof/${{ matrix.asset_target }}" + work_dir="$RUNNER_TEMP/codestory-notary-${{ matrix.asset_target }}" + keychain="$RUNNER_TEMP/codestory-signing-${{ matrix.asset_target }}.keychain-db" + keychain_password="$(openssl rand -hex 24)" + mkdir -p "$proof_dir" "$work_dir/stage" + printf '%s' "$APPLE_DEVELOPER_ID_P12_BASE64" | base64 -D > "$work_dir/developer-id.p12" + printf '%s' "$APPLE_NOTARY_KEY_P8_BASE64" | base64 -D > "$work_dir/notary-key.p8" + chmod 600 "$work_dir/developer-id.p12" "$work_dir/notary-key.p8" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$work_dir/developer-id.p12" \ + -k "$keychain" \ + -P "$APPLE_DEVELOPER_ID_P12_PASSWORD" \ + -T /usr/bin/codesign + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$keychain_password" \ + "$keychain" + security list-keychains -d user -s "$keychain" + security find-identity -v -p codesigning "$keychain" | tee "$proof_dir/signing-identities.txt" + signing_hash="$(awk -v identity="$APPLE_SIGNING_IDENTITY" \ + 'index($0, "\"" identity "\"") { print $2; exit }' \ + "$proof_dir/signing-identities.txt")" + grep -Eq '^[0-9A-Fa-f]{40}$' <<<"$signing_hash" + codesign \ + --force \ + --options runtime \ + --timestamp \ + --keychain "$keychain" \ + --sign "$signing_hash" \ + "$bin" + codesign --verify --strict --verbose=2 "$bin" + codesign -dv --verbose=4 "$bin" 2> "$proof_dir/codesign-details.txt" + codesign -d -r- "$bin" > "$proof_dir/designated-requirement.txt" 2>&1 + grep -F "Authority=$APPLE_SIGNING_IDENTITY" "$proof_dir/codesign-details.txt" + grep -E "flags=.*runtime" "$proof_dir/codesign-details.txt" + grep -E "^TeamIdentifier=${APPLE_DEVELOPER_TEAM_ID}$" "$proof_dir/codesign-details.txt" + grep -E "certificate leaf\\[subject\\.OU\\] = \"?${APPLE_DEVELOPER_TEAM_ID}\"?([[:space:]]|$)" \ + "$proof_dir/designated-requirement.txt" + + cp "$bin" "$work_dir/stage/codestory-cli" + ditto -c -k "$work_dir/stage" "$work_dir/codestory-cli-notary.zip" + xcrun notarytool submit "$work_dir/codestory-cli-notary.zip" \ + --key "$work_dir/notary-key.p8" \ + --key-id "$APPLE_NOTARY_KEY_ID" \ + --issuer "$APPLE_NOTARY_ISSUER_ID" \ + --no-wait \ + --output-format json | tee "$proof_dir/notarytool-submission.json" + submission_id="$(jq -er \ + '.id | select(type == "string" and test("^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$"))' \ + "$proof_dir/notarytool-submission.json")" + printf '%s\n' "$submission_id" > "$proof_dir/notarytool-submission-id.txt" + + notary_accepted=false + max_notary_attempts=120 + notary_poll_seconds=30 + notary_poll_log="$proof_dir/notarytool-poll.log" + for attempt in $(seq 1 "$max_notary_attempts"); do + notary_info="$work_dir/notarytool-info.json" + notary_error="$work_dir/notarytool-info-error.txt" + if xcrun notarytool info "$submission_id" \ + --key "$work_dir/notary-key.p8" \ + --key-id "$APPLE_NOTARY_KEY_ID" \ + --issuer "$APPLE_NOTARY_ISSUER_ID" \ + --output-format json > "$notary_info" 2> "$notary_error" + then + if notary_status="$(jq -er '.status | select(type == "string")' "$notary_info")" + then + cp "$notary_info" "$proof_dir/notarytool-result.json" + printf 'attempt=%s status=%s\n' "$attempt" "$notary_status" >> "$notary_poll_log" + case "$notary_status" in + Accepted) + notary_accepted=true + break + ;; + "In Progress") + ;; + Invalid|Rejected) + notary_log_retained=false + for log_attempt in 1 2 3; do + if xcrun notarytool log "$submission_id" \ + --key "$work_dir/notary-key.p8" \ + --key-id "$APPLE_NOTARY_KEY_ID" \ + --issuer "$APPLE_NOTARY_ISSUER_ID" \ + "$proof_dir/notarytool-log.json" \ + >> "$notary_poll_log" 2>&1 \ + && [ -s "$proof_dir/notarytool-log.json" ] + then + notary_log_retained=true + break + fi + if [ "$log_attempt" -lt 3 ]; then + sleep 5 + fi + done + if [ "$notary_log_retained" != true ]; then + printf 'Apple rejected submission %s, but its notarization log could not be retrieved.\n' \ + "$submission_id" > "$proof_dir/notarytool-log-unavailable.txt" + fi + cat "$proof_dir/notarytool-result.json" + echo "::error::Apple rejected notarization submission $submission_id with status $notary_status" + exit 1 + ;; + *) + cat "$proof_dir/notarytool-result.json" + echo "::error::Apple returned unexpected notarization status '$notary_status' for $submission_id" + exit 1 + ;; + esac + else + printf 'attempt=%s malformed_info_response\n' "$attempt" >> "$notary_poll_log" + cat "$notary_info" >> "$notary_poll_log" + fi + else + printf 'attempt=%s transport_error\n' "$attempt" >> "$notary_poll_log" + cat "$notary_error" >> "$notary_poll_log" + fi + if [ "$attempt" -lt "$max_notary_attempts" ]; then + sleep "$notary_poll_seconds" + fi + done + if [ "$notary_accepted" != true ]; then + tail -n 40 "$notary_poll_log" || true + echo "::error::Notarization timed out after $((max_notary_attempts * notary_poll_seconds / 60)) minutes for submission $submission_id" + exit 1 + fi + jq -e '.status == "Accepted"' "$proof_dir/notarytool-result.json" + + cp "$bin" "$work_dir/codestory-cli-quarantined" + xattr -w com.apple.quarantine "0083;$(date +%s);CodeStory;" "$work_dir/codestory-cli-quarantined" + accepted=false + for _attempt in $(seq 1 12); do + if spctl --assess --type execute --verbose=4 "$work_dir/codestory-cli-quarantined" \ + > "$proof_dir/spctl.txt" 2>&1 + then + accepted=true + break + fi + sleep 5 + done + if [ "$accepted" != true ]; then + cat "$proof_dir/spctl.txt" + echo "::error::Gatekeeper did not accept the signed and notarized CLI" + exit 1 + fi + grep -F "source=Notarized Developer ID" "$proof_dir/spctl.txt" + + - name: Execute notarized macOS CLI without signing credentials + if: runner.os == 'macOS' && inputs.sign_macos + shell: bash + run: | + work_dir="$RUNNER_TEMP/codestory-notary-${{ matrix.asset_target }}" + "$work_dir/codestory-cli-quarantined" --version + "$work_dir/codestory-cli-quarantined" --help >/dev/null - name: Smoke codestory-cli if: runner.os != 'Windows' @@ -105,6 +336,27 @@ jobs: --binary "$bin" \ --out-dir target/release-dist + - name: Prove Linux x64 glibc 2.31 baseline + if: matrix.asset_target == 'linux-x64' + run: | + docker run --rm --platform linux/amd64 \ + --volume "$PWD:/workspace" \ + --workdir /workspace \ + "${{ env.LINUX_GLIBC_BASELINE_IMAGE }}" \ + bash .github/scripts/check-linux-glibc-baseline.sh \ + "target/release-dist/codestory-cli-v${{ inputs.version }}-linux-x64.tar.gz" \ + "${{ inputs.version }}" \ + target/linux-glibc-baseline \ + "glibc 2.31" + + - name: Upload Linux glibc baseline proof + if: always() && matrix.asset_target == 'linux-x64' + uses: actions/upload-artifact@v7.0.1 + with: + name: linux-glibc-2.31-baseline-proof + path: target/linux-glibc-baseline + if-no-files-found: error + - name: Smoke packaged release asset if: runner.os != 'Windows' run: | @@ -162,6 +414,25 @@ jobs: --version-only ` --out-dir "target/packaged-version-smoke/${{ matrix.asset_target }}" + - name: Prove Intel macOS backend policy and explicit CPU/external operation + if: matrix.asset_target == 'macos-x64' + run: >- + python3 .github/scripts/check-packaged-agent-proof.py + --archive "target/release-dist/codestory-cli-v${{ inputs.version }}-macos-x64.tar.gz" + --checksum-file target/release-dist/SHA256SUMS.txt + --expected-version "${{ inputs.version }}" + --project "${{ github.workspace }}" + --intel-runtime-policy + --out-dir target/packaged-intel-policy-proof + + - name: Upload Intel macOS policy proof + if: always() && matrix.asset_target == 'macos-x64' + uses: actions/upload-artifact@v7.0.1 + with: + name: packaged-intel-policy-proof-${{ matrix.asset_target }} + path: target/packaged-intel-policy-proof + if-no-files-found: warn + - name: Upload packaged version proof if: always() uses: actions/upload-artifact@v7.0.1 @@ -175,12 +446,10 @@ jobs: shell: pwsh run: pwsh -File scripts/install-codestory.ps1 -SelfTest - - name: Prove Windows managed plugin handoff - if: matrix.asset_target == 'windows-x64' - shell: pwsh + - name: Prove managed plugin handoff run: >- python .github/scripts/check-packaged-agent-proof.py - --archive "target/release-dist/codestory-cli-v${{ inputs.version }}-${{ matrix.asset_target }}.zip" + --archive "target/release-dist/codestory-cli-v${{ inputs.version }}-${{ matrix.asset_target }}.${{ matrix.extension }}" --checksum-file target/release-dist/SHA256SUMS.txt --expected-version "${{ inputs.version }}" --project "${{ github.workspace }}" @@ -188,8 +457,8 @@ jobs: --managed-plugin-handoff --out-dir target/packaged-managed-proof - - name: Upload Windows managed plugin proof - if: always() && matrix.asset_target == 'windows-x64' + - name: Upload managed plugin proof + if: always() uses: actions/upload-artifact@v7.0.1 with: name: packaged-managed-proof-${{ matrix.asset_target }} @@ -204,8 +473,24 @@ jobs: target/release-dist/*.tar.gz target/release-dist/*.zip target/release-dist/*.sha256 + target/release-dist/SHA256SUMS.txt + if-no-files-found: error + + - name: Upload macOS notarization proof + if: always() && runner.os == 'macOS' && inputs.sign_macos + uses: actions/upload-artifact@v7.0.1 + with: + name: macos-notarization-proof-${{ matrix.asset_target }} + path: target/notarization-proof/${{ matrix.asset_target }} if-no-files-found: error + - name: Remove temporary macOS signing material + if: always() && runner.os == 'macOS' && inputs.sign_macos + shell: bash + run: | + security delete-keychain "$RUNNER_TEMP/codestory-signing-${{ matrix.asset_target }}.keychain-db" || true + rm -rf "$RUNNER_TEMP/codestory-notary-${{ matrix.asset_target }}" + - name: Save Cargo registry, git sources, and build output if: always() && steps.cargo-cache-restore.outputs.cache-hit != 'true' && steps.cargo-cache-restore.outputs.cache-primary-key != '' uses: actions/cache/save@v5 diff --git a/.github/workflows/plugin-static.yml b/.github/workflows/plugin-static.yml index 6f7e46fd..c13c3eeb 100644 --- a/.github/workflows/plugin-static.yml +++ b/.github/workflows/plugin-static.yml @@ -10,6 +10,10 @@ on: - .github/scripts/check-packaged-agent-proof.py - .github/scripts/package-codestory-release.py - .github/scripts/check-workflow-policy.mjs + - .github/scripts/check-workflow-policy.test.mjs + - package.json + - package-lock.json + - .github/scripts/route-ci-proof.mjs - .github/workflows/auto-release.yml - .github/workflows/main-branch-source-guard.yml - .github/workflows/rust-ci.yml @@ -17,8 +21,15 @@ on: - .github/workflows/post-publish-release-smoke.yml - .github/workflows/packaged-platform-pr.yml - .github/workflows/packaged-platform-proof.yml + - .github/workflows/macos-metal-proof.yml + - .github/workflows/source-proof.yml + - .github/workflows/repo-scale-stats.yml - .github/workflows/plugin-static.yml - scripts/install-codestory.ps1 + - scripts/codex-worktree-setup.* + - scripts/tests/codex-worktree-setup.test.mjs + - scripts/setup-retrieval-env.* + - .codex/environments/environment.toml push: branches: - main @@ -31,6 +42,10 @@ on: - .github/scripts/check-packaged-agent-proof.py - .github/scripts/package-codestory-release.py - .github/scripts/check-workflow-policy.mjs + - .github/scripts/check-workflow-policy.test.mjs + - package.json + - package-lock.json + - .github/scripts/route-ci-proof.mjs - .github/workflows/auto-release.yml - .github/workflows/main-branch-source-guard.yml - .github/workflows/rust-ci.yml @@ -38,8 +53,15 @@ on: - .github/workflows/post-publish-release-smoke.yml - .github/workflows/packaged-platform-pr.yml - .github/workflows/packaged-platform-proof.yml + - .github/workflows/macos-metal-proof.yml + - .github/workflows/source-proof.yml + - .github/workflows/repo-scale-stats.yml - .github/workflows/plugin-static.yml - scripts/install-codestory.ps1 + - scripts/codex-worktree-setup.* + - scripts/tests/codex-worktree-setup.test.mjs + - scripts/setup-retrieval-env.* + - .codex/environments/environment.toml workflow_dispatch: permissions: @@ -57,11 +79,19 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Install workflow policy dependencies + run: npm ci --ignore-scripts + - name: Check plugin static wiring run: node --test plugins/codestory/tests/plugin-static.test.mjs - name: Check workflow policy - run: node .github/scripts/check-workflow-policy.mjs + run: | + node .github/scripts/check-workflow-policy.mjs + node --test .github/scripts/check-workflow-policy.test.mjs + + - name: Check CI proof routing fixtures + run: node .github/scripts/route-ci-proof.mjs --self-test - name: Check packaged proof harness run: python .github/scripts/check-packaged-agent-proof.py --self-test diff --git a/.github/workflows/post-publish-release-smoke.yml b/.github/workflows/post-publish-release-smoke.yml index e944aa7c..c877cb8d 100644 --- a/.github/workflows/post-publish-release-smoke.yml +++ b/.github/workflows/post-publish-release-smoke.yml @@ -17,6 +17,10 @@ on: permissions: contents: read +env: + RELEASE_RUST_TOOLCHAIN: "1.95.0" + APPLE_DEVELOPER_TEAM_ID: "PKUJNR8D6F" + jobs: smoke: name: Published ${{ matrix.asset_target }} smoke @@ -38,6 +42,9 @@ jobs: - os: windows-11-arm asset_target: windows-arm64 extension: zip + - os: macos-15-intel + asset_target: macos-x64 + extension: tar.gz - os: macos-15 asset_target: macos-arm64 extension: tar.gz @@ -93,14 +100,69 @@ jobs: --version-only --out-dir "target/post-publish-version-proof/${{ matrix.asset_target }}" + - name: Prove published macOS signature, notarization, and Gatekeeper acceptance + if: runner.os == 'macOS' + shell: bash + run: | + set -euo pipefail + proof_dir="target/post-publish-macos-signing/${{ matrix.asset_target }}" + unpacked="$proof_dir/unpacked" + mkdir -p "$unpacked" + quarantine="0083;$(date +%s);CodeStory;" + xattr -w com.apple.quarantine "$quarantine" "${{ steps.asset.outputs.archive }}" + xattr -p com.apple.quarantine "${{ steps.asset.outputs.archive }}" \ + > "$proof_dir/archive-quarantine.txt" + tar -xzf "${{ steps.asset.outputs.archive }}" -C "$unpacked" + bins="$(find "$unpacked" -type f -name codestory-cli -print)" + count="$(printf '%s\n' "$bins" | sed '/^$/d' | wc -l | tr -d ' ')" + if [ "$count" -ne 1 ]; then + echo "::error::Expected one codestory-cli in the macOS archive, found $count" + exit 1 + fi + bin="$bins" + chmod +x "$bin" + codesign --verify --strict --verbose=2 "$bin" + codesign -dv --verbose=4 "$bin" 2> "$proof_dir/codesign-details.txt" + codesign -d -r- "$bin" > "$proof_dir/designated-requirement.txt" 2>&1 + grep -F "Authority=Developer ID Application:" "$proof_dir/codesign-details.txt" + grep -E "flags=.*runtime" "$proof_dir/codesign-details.txt" + grep -E "^TeamIdentifier=${APPLE_DEVELOPER_TEAM_ID}$" "$proof_dir/codesign-details.txt" + grep -E "certificate leaf\\[subject\\.OU\\] = \"?${APPLE_DEVELOPER_TEAM_ID}\"?([[:space:]]|$)" \ + "$proof_dir/designated-requirement.txt" + # Command-line tar does not propagate the download quarantine xattr, + # so retain the archive evidence and explicitly transfer the same + # quarantine event to the extracted executable before assessment. + xattr -w com.apple.quarantine "$quarantine" "$bin" + xattr -p com.apple.quarantine "$bin" > "$proof_dir/extracted-binary-quarantine.txt" + accepted=false + for _attempt in $(seq 1 12); do + if spctl --assess --type execute --verbose=4 "$bin" > "$proof_dir/spctl.txt" 2>&1; then + accepted=true + break + fi + sleep 5 + done + if [ "$accepted" != true ]; then + cat "$proof_dir/spctl.txt" + exit 1 + fi + grep -F "source=Notarized Developer ID" "$proof_dir/spctl.txt" + "$bin" --version + "$bin" --help >/dev/null + - name: Run Windows installer ownership self-test if: matrix.asset_target == 'windows-x64' shell: pwsh run: pwsh -File scripts/install-codestory.ps1 -SelfTest - - name: Prove Windows managed plugin handoff - if: matrix.asset_target == 'windows-x64' + - name: Install pinned Rust for Windows rollback proof + if: runner.os == 'Windows' shell: pwsh + run: | + rustup toolchain install "${{ env.RELEASE_RUST_TOOLCHAIN }}" --profile minimal + rustup default "${{ env.RELEASE_RUST_TOOLCHAIN }}" + + - name: Prove managed plugin handoff run: >- python .github/scripts/check-packaged-agent-proof.py --archive "${{ steps.asset.outputs.archive }}" @@ -111,6 +173,17 @@ jobs: --managed-plugin-handoff --out-dir target/post-publish-managed-proof + - name: Prove published Intel macOS backend policy and explicit CPU/external operation + if: matrix.asset_target == 'macos-x64' + run: >- + python3 .github/scripts/check-packaged-agent-proof.py + --archive "${{ steps.asset.outputs.archive }}" + --checksum-file "${{ steps.asset.outputs.checksum }}" + --expected-version "${{ steps.release.outputs.version }}" + --project "${{ github.workspace }}" + --intel-runtime-policy + --out-dir target/post-publish-intel-policy-proof + - name: Fetch retrieval embedding model if: matrix.asset_target == 'linux-x64' run: node scripts/setup-retrieval-env.mjs --fetch-embed-model --fetch-only @@ -137,4 +210,6 @@ jobs: target/post-publish-version-proof/${{ matrix.asset_target }} target/post-publish-managed-proof target/post-publish-agent-proof + target/post-publish-intel-policy-proof + target/post-publish-macos-signing/${{ matrix.asset_target }} if-no-files-found: warn diff --git a/.github/workflows/release-candidate-evidence.yml b/.github/workflows/release-candidate-evidence.yml new file mode 100644 index 00000000..fca45d34 --- /dev/null +++ b/.github/workflows/release-candidate-evidence.yml @@ -0,0 +1,193 @@ +name: Release candidate evidence + +on: + workflow_call: + inputs: + ref: + required: true + type: string + proof_key: + required: true + type: string + profile: + required: true + type: string + drill_manifest: + required: false + type: string + default: "" + embedding_model_dir: + required: false + type: string + default: "" + source_run_id: + required: false + type: string + default: "" + secrets: + CODESTORY_RELEASE_EVIDENCE_APPROVAL_JSON: + required: false + +permissions: + actions: read + contents: read + +concurrency: + group: release-candidate-evidence-${{ inputs.proof_key }} + cancel-in-progress: true + +jobs: + measure: + name: Measure release candidate + environment: release-evidence + runs-on: [self-hosted, Linux, ARM64, codestory-release-evidence] + timeout-minutes: 90 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Record and validate runner provisioning + if: inputs.source_run_id == '' + shell: bash + env: + PROFILE: ${{ inputs.profile }} + DRILL_MANIFEST: ${{ inputs.drill_manifest }} + EMBEDDING_MODEL_DIR: ${{ inputs.embedding_model_dir }} + run: | + set -euo pipefail + test "$DRILL_MANIFEST" = /srv/codestory-release-evidence/drills/real-repo-drill-cases.json + test "$EMBEDDING_MODEL_DIR" = /srv/codestory-release-evidence/models + bash scripts/release-evidence/guest-verify.sh \ + scripts/release-evidence/machine-contract.json + mkdir -p target/release-evidence + cp /srv/codestory-release-evidence/artifacts/provisioning.json \ + target/release-evidence/provisioning.json + provisioned_profile="$(jq -er 'select(.schema_version == 2) | .profile_id' \ + target/release-evidence/provisioning.json)" + test "$PROFILE" = "$provisioned_profile" || { + echo "::error::Requested profile $PROFILE does not match provisioned profile $provisioned_profile." + exit 1 + } + + - name: Build release CLI + if: inputs.source_run_id == '' + run: cargo build --release --locked -p codestory-cli + + - name: Capture machine fingerprint + if: inputs.source_run_id == '' + id: machine + shell: bash + env: + CODESTORY_RELEASE_EVIDENCE_PROVISIONING: ${{ github.workspace }}/target/release-evidence/provisioning.json + run: echo "fingerprint=$(node scripts/codestory-release-evidence-gate.mjs fingerprint)" >> "$GITHUB_OUTPUT" + + - name: Produce full-sidecar repo evidence + if: inputs.source_run_id == '' + shell: bash + env: + CODESTORY_RELEASE_EVIDENCE_DRILL_OUTPUT_DIR: ${{ github.workspace }}/target/release-evidence/drill + CODESTORY_RELEASE_EVIDENCE_STATS_PATH: ${{ github.workspace }}/target/release-evidence/stats.json + CODESTORY_RELEASE_EVIDENCE_COMMIT: ${{ inputs.ref }} + CODESTORY_RELEASE_EVIDENCE_CORPUS_ID: codestory-release-corpus-v1 + CODESTORY_RELEASE_EVIDENCE_CACHE_ID: cold-full-sidecar-v1 + CODESTORY_RELEASE_EVIDENCE_MACHINE_FINGERPRINT: ${{ steps.machine.outputs.fingerprint }} + CODESTORY_REAL_REPO_DRILL_CASES: ${{ inputs.drill_manifest }} + CODESTORY_EMBED_ALLOW_CPU: "1" + CODESTORY_EMBED_MODEL_DIR: ${{ inputs.embedding_model_dir }} + run: | + set -euo pipefail + cargo test --locked -p codestory-cli --test codestory_repo_e2e_stats -- --ignored --nocapture --test-threads=1 \ + 2>&1 | tee target/release-evidence/repo-e2e-output.txt + + - name: Produce publishable packet evidence + if: inputs.source_run_id == '' + shell: bash + env: + CODESTORY_RELEASE_EVIDENCE_COMMIT: ${{ inputs.ref }} + CODESTORY_RELEASE_EVIDENCE_PROFILE: ${{ inputs.profile }} + CODESTORY_RELEASE_EVIDENCE_CORPUS_ID: codestory-release-corpus-v1 + CODESTORY_RELEASE_EVIDENCE_CACHE_ID: cold-full-sidecar-v1 + CODESTORY_RELEASE_EVIDENCE_MACHINE_FINGERPRINT: ${{ steps.machine.outputs.fingerprint }} + CODESTORY_EMBED_ALLOW_CPU: "1" + CODESTORY_EMBED_MODEL_DIR: ${{ inputs.embedding_model_dir }} + run: | + set -euo pipefail + node scripts/codestory-agent-ab-benchmark.mjs \ + --packet-runtime \ + --packet-runtime-mode cold-cli \ + --task-suite holdout-retrieval \ + --materialize-repos \ + --repeats 3 \ + --publishable \ + --max-source-reads-after-packet 0 \ + --codestory-cli target/release/codestory-cli \ + --timeout-ms 180000 \ + --out-dir target/release-evidence/packet + + - name: Download prior rejected evidence for approval re-evaluation + if: inputs.source_run_id != '' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + EVIDENCE_SHA: ${{ inputs.ref }} + SOURCE_RUN_ID: ${{ inputs.source_run_id }} + run: | + set -euo pipefail + mkdir -p target/release-evidence + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID" \ + > target/release-evidence/source-run.json + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID/artifacts" \ + > target/release-evidence/source-run-artifacts.json + node scripts/codestory-release-evidence-gate.mjs validate-source-run \ + --run target/release-evidence/source-run.json \ + --artifacts target/release-evidence/source-run-artifacts.json \ + --repo "$GITHUB_REPOSITORY" \ + --expected-sha "$EVIDENCE_SHA" + gh run download "$SOURCE_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --name "release-evidence-$EVIDENCE_SHA" \ + --dir target/release-evidence + + - name: Produce and evaluate same-SHA candidate + shell: bash + env: + PROFILE: ${{ inputs.profile }} + APPROVAL_JSON: ${{ secrets.CODESTORY_RELEASE_EVIDENCE_APPROVAL_JSON }} + EVIDENCE_SHA: ${{ inputs.ref }} + SOURCE_RUN_ID: ${{ inputs.source_run_id }} + run: | + set -euo pipefail + if [ -z "$SOURCE_RUN_ID" ]; then + node scripts/codestory-release-evidence-gate.mjs produce \ + --baseline benchmarks/release-evidence/approved-baselines.json \ + --profile "$PROFILE" \ + --stats target/release-evidence/stats.json \ + --packet target/release-evidence/packet/packet-runtime-summary.json \ + --out target/release-evidence/candidate.json \ + --expected-sha "$EVIDENCE_SHA" \ + --mode release \ + --repo "$GITHUB_WORKSPACE" + fi + approval_args=() + if [ -n "$APPROVAL_JSON" ]; then + printf '%s' "$APPROVAL_JSON" > target/release-evidence/approval.json + approval_args=(--approval target/release-evidence/approval.json) + fi + node scripts/codestory-release-evidence-gate.mjs evaluate \ + --baseline benchmarks/release-evidence/approved-baselines.json \ + --candidate target/release-evidence/candidate.json \ + --out target/release-evidence/report.json \ + --expected-sha "$EVIDENCE_SHA" \ + --mode release \ + --repo "$GITHUB_WORKSPACE" \ + "${approval_args[@]}" + + - name: Upload release evidence + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: release-evidence-${{ inputs.ref }} + path: target/release-evidence + if-no-files-found: warn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 500cc7c7..ceea992e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,14 +7,25 @@ on: description: "Release version from crates/codestory-cli/Cargo.toml, for example 0.7.0" required: true type: string + source_run_id: + description: "Optional rejected release-evidence run to re-evaluate without measuring again" + required: false + type: string + default: "" workflow_dispatch: inputs: version: description: "Release version from crates/codestory-cli/Cargo.toml, for example 0.7.0" required: true type: string + source_run_id: + description: "Optional rejected release-evidence run to re-evaluate without measuring again" + required: false + type: string + default: "" permissions: + actions: read contents: read concurrency: @@ -30,7 +41,10 @@ jobs: - name: Checkout uses: actions/checkout@v5 - - name: Enforce third-party action pinning + - name: Install workflow policy dependencies + run: npm ci --ignore-scripts + + - name: Enforce workflow policy run: node .github/scripts/check-workflow-policy.mjs preflight: @@ -66,6 +80,14 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=v$version" >> "$GITHUB_OUTPUT" + - name: Validate versioned changelog notes + env: + VERSION: ${{ steps.version.outputs.version }} + run: >- + node .github/scripts/extract-codestory-release-notes.mjs + --version "$VERSION" + --output "$RUNNER_TEMP/codestory-release-notes.md" + - name: Refuse existing tag or release env: GH_TOKEN: ${{ github.token }} @@ -81,17 +103,56 @@ jobs: exit 1 fi - packaged-proof: + release-evidence: needs: preflight + uses: ./.github/workflows/release-candidate-evidence.yml + with: + ref: ${{ github.sha }} + proof_key: release-${{ needs.preflight.outputs.version }} + profile: codestory-release-evidence-linux-arm64-v1 + drill_manifest: /srv/codestory-release-evidence/drills/real-repo-drill-cases.json + embedding_model_dir: /srv/codestory-release-evidence/models + source_run_id: ${{ inputs.source_run_id }} + + packaged-proof: + needs: + - preflight + - release-evidence uses: ./.github/workflows/packaged-platform-proof.yml with: version: ${{ needs.preflight.outputs.version }} + ref: ${{ github.sha }} + scope: full + proof_key: release-${{ needs.preflight.outputs.version }} + sign_macos: true + # Provision these names as environment secrets on macos-release-signing. + # That protected environment is selected by the called macOS matrix jobs, + # and its values take precedence over caller/repository secrets. + secrets: + APPLE_DEVELOPER_ID_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} + APPLE_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_NOTARY_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }} + APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + + macos-metal-proof: + needs: + - preflight + - packaged-proof + uses: ./.github/workflows/macos-metal-proof.yml + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ github.sha }} + proof_key: release-${{ needs.preflight.outputs.version }} + use_packaged_cli_artifact: true publish: name: Publish GitHub release needs: - preflight - packaged-proof + - macos-metal-proof runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -116,6 +177,28 @@ jobs: test -s target/release-assets/SHA256SUMS.txt (cd target/release-assets && sha256sum -c SHA256SUMS.txt) + - name: Compose versioned GitHub release notes + env: + VERSION: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + node .github/scripts/extract-codestory-release-notes.mjs \ + --version "$VERSION" \ + --output target/release-assets/release-notes.md + cat >> target/release-assets/release-notes.md <<'EOF' + + ## Platform proof boundaries + + This release ships managed CLI assets for Windows x64/arm64, Linux x64/arm64, and macOS x64/arm64. + + - Windows x64 managed install, local ground, and repair handoff are acceptance-smoked; this is not full sidecar or GPU proof. + - Apple Silicon release assets are Developer ID signed, notarized, and gated on packaged managed-Metal cold/warm reuse, dead-endpoint blocking, recovery, packet, and search proof. + - Intel macOS packaging is acceptance-smoked; it does not make Apple Silicon Metal claims. + - Windows arm64 ships a CLI asset but has no managed native accelerated embedding cell. + - Linux accelerator claims still require the evidence tiers in `docs/contributors/testing-matrix.md`. + - Linux x64 is packaged and executed at the glibc 2.31 baseline; this does not claim musl support or Linux arm64 baseline parity. + EOF + - name: Refuse existing tag or release env: GH_TOKEN: ${{ github.token }} @@ -139,33 +222,23 @@ jobs: run: | set -euo pipefail shopt -s nullglob + # Release globs must expand into individual assets. + # shellcheck disable=SC2206 assets=( target/release-assets/codestory-cli-v${VERSION}-*.tar.gz target/release-assets/codestory-cli-v${VERSION}-*.zip target/release-assets/SHA256SUMS.txt ) - if [ "${#assets[@]}" -ne 6 ]; then - echo "::error::Expected five binary archives plus SHA256SUMS.txt, found ${#assets[@]} assets." + if [ "${#assets[@]}" -ne 7 ]; then + echo "::error::Expected six binary archives plus SHA256SUMS.txt, found ${#assets[@]} assets." printf '%s\n' "${assets[@]}" exit 1 fi - cat > target/release-assets/proof-boundaries.md <<'EOF' - ## Platform proof boundaries - - This release ships managed CLI assets for Windows x64/arm64, Linux x64/arm64, and macOS arm64. - - - Windows x64 managed install, local ground, and repair handoff are acceptance-smoked; this is not full sidecar or GPU proof. - - Apple Silicon packaging is acceptance-smoked, while live managed Metal endpoint survival remains open in #887. - - Windows arm64 ships a CLI asset but has no managed native accelerated embedding cell. - - Linux accelerator claims still require the evidence tiers in `docs/contributors/testing-matrix.md`. - - Older-glibc compatibility is unproven; execution on the current hosted Ubuntu runner does not establish it. - EOF gh release create "$TAG" "${assets[@]}" \ --repo "$GITHUB_REPOSITORY" \ --target "$GITHUB_SHA" \ --title "CodeStory $TAG" \ - --notes-file target/release-assets/proof-boundaries.md \ - --generate-notes + --notes-file target/release-assets/release-notes.md post-publish-smoke: name: Post-publish release asset smoke diff --git a/.github/workflows/repo-scale-stats.yml b/.github/workflows/repo-scale-stats.yml new file mode 100644 index 00000000..bf599cad --- /dev/null +++ b/.github/workflows/repo-scale-stats.yml @@ -0,0 +1,70 @@ +name: Promoted repo-scale stats + +on: + workflow_call: + inputs: + ref: + required: true + type: string + proof_key: + required: true + type: string + +permissions: + contents: read + +concurrency: + group: repo-scale-stats-${{ inputs.proof_key }} + cancel-in-progress: true + +jobs: + stats: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref }} + - name: Install Rust stable + run: | + rustup toolchain install stable --profile minimal + rustup default stable + - name: Capture Rust cache identity + id: rust-cache-key + shell: bash + run: | + echo "version=$(rustc -Vv | sed -n 's/^release: //p')" >> "$GITHUB_OUTPUT" + echo "target=$(rustc -Vv | sed -n 's/^host: //p')" >> "$GITHUB_OUTPUT" + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-repo-scale-${{ steps.rust-cache-key.outputs.version }}-${{ steps.rust-cache-key.outputs.target }}-codestory-cli-default-features-${{ hashFiles('Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-repo-scale-${{ steps.rust-cache-key.outputs.version }}-${{ steps.rust-cache-key.outputs.target }}-codestory-cli-default-features- + - name: Fetch the checksum-pinned embedding model + env: + CODESTORY_EMBED_MODEL_DIR: ${{ runner.temp }}/codestory-stats-models + run: node scripts/setup-retrieval-env.mjs --fetch-embed-model --fetch-only + - name: Build the release CLI + run: cargo build --release --locked -p codestory-cli + - name: Run mandatory repo-scale stats once + shell: bash + env: + CODESTORY_ALLOW_SKIP_REAL_REPO_DRILL_CASES: "1" + CODESTORY_EMBED_ALLOW_CPU: "1" + CODESTORY_EMBED_MODEL_DIR: ${{ runner.temp }}/codestory-stats-models + run: | + set -euo pipefail + mkdir -p target/repo-scale-stats + cargo test --locked -p codestory-cli --test codestory_repo_e2e_stats -- --ignored --nocapture \ + 2>&1 | tee target/repo-scale-stats/output.txt + - name: Upload repo-scale stats output + if: always() + uses: actions/upload-artifact@v7.0.1 + with: + name: repo-scale-stats-${{ inputs.proof_key }} + path: target/repo-scale-stats + if-no-files-found: error diff --git a/.github/workflows/retrieval-sidecar-smoke.yml b/.github/workflows/retrieval-sidecar-smoke.yml index a0185807..13d09a20 100644 --- a/.github/workflows/retrieval-sidecar-smoke.yml +++ b/.github/workflows/retrieval-sidecar-smoke.yml @@ -6,60 +6,46 @@ name: retrieval-sidecar-smoke on: pull_request: paths: - - crates/codestory-retrieval/** - - crates/codestory-contracts/** - - crates/codestory-store/Cargo.toml - - crates/codestory-store/src/** - - crates/codestory-cli/src/retrieval.rs - - crates/codestory-cli/src/main.rs - - crates/codestory-cli/src/args.rs - - crates/codestory-cli/src/runtime.rs - - crates/codestory-cli/src/stdio_*.rs - - crates/codestory-cli/tests/fixtures/packet_search_eval/** - - crates/codestory-cli/tests/packet_search_eval.rs - - crates/codestory-cli/tests/retrieval_bootstrap_contracts.rs - - crates/codestory-cli/tests/search_json_output.rs - - crates/codestory-cli/tests/stdio_protocol_contracts.rs - - crates/codestory-runtime/src/** - - crates/codestory-indexer/Cargo.toml - - crates/codestory-indexer/src/lib.rs - - scripts/lint-retrieval-generalization.mjs - - scripts/**retrieval** + - .codex/environments/** + - .cursor/rules/** + - .github/actions/** + - .github/scripts/** + - .github/workflows/** + - benchmarks/** + - crates/** + - docker/** + - docs/architecture/language-support.md + - docs/architecture/retrieval-* + - docs/contributors/testing-matrix.md - docs/ops/retrieval-sidecars.md - - docs/architecture/retrieval-*.md + - docs/testing/codestory-e2e-stats-log.md + - docs/testing/performance-review-playbook.md - docs/testing/retrieval-architecture.md - - docker/retrieval-compose.yml - - .github/workflows/retrieval-sidecar-smoke.yml + - plugins/codestory/** + - scripts/** # Base-branch runs seed caches that sibling PRs are allowed to restore. push: branches: - main - dev/codestory-next paths: - - crates/codestory-retrieval/** - - crates/codestory-contracts/** - - crates/codestory-store/Cargo.toml - - crates/codestory-store/src/** - - crates/codestory-cli/src/retrieval.rs - - crates/codestory-cli/src/main.rs - - crates/codestory-cli/src/args.rs - - crates/codestory-cli/src/runtime.rs - - crates/codestory-cli/src/stdio_*.rs - - crates/codestory-cli/tests/fixtures/packet_search_eval/** - - crates/codestory-cli/tests/packet_search_eval.rs - - crates/codestory-cli/tests/retrieval_bootstrap_contracts.rs - - crates/codestory-cli/tests/search_json_output.rs - - crates/codestory-cli/tests/stdio_protocol_contracts.rs - - crates/codestory-runtime/src/** - - crates/codestory-indexer/Cargo.toml - - crates/codestory-indexer/src/lib.rs - - scripts/lint-retrieval-generalization.mjs - - scripts/**retrieval** + - .codex/environments/** + - .cursor/rules/** + - .github/actions/** + - .github/scripts/** + - .github/workflows/** + - benchmarks/** + - crates/** + - docker/** + - docs/architecture/language-support.md + - docs/architecture/retrieval-* + - docs/contributors/testing-matrix.md - docs/ops/retrieval-sidecars.md - - docs/architecture/retrieval-*.md + - docs/testing/codestory-e2e-stats-log.md + - docs/testing/performance-review-playbook.md - docs/testing/retrieval-architecture.md - - docker/retrieval-compose.yml - - .github/workflows/retrieval-sidecar-smoke.yml + - plugins/codestory/** + - scripts/** workflow_dispatch: concurrency: @@ -81,6 +67,9 @@ jobs: - name: Generalization lint (production paths) run: node scripts/lint-retrieval-generalization.mjs + - name: Release evidence gate contracts + run: node --test scripts/tests/codestory-release-evidence-gate.test.mjs + - name: Install Rust stable run: | rustup toolchain install stable --profile minimal @@ -91,6 +80,7 @@ jobs: shell: bash run: | echo "version=$(rustc -Vv | sed -n 's/^release: //p')" >> "$GITHUB_OUTPUT" + echo "target=$(rustc -Vv | sed -n 's/^host: //p')" >> "$GITHUB_OUTPUT" - name: Restore Cargo registry, git sources, and build output id: cargo-cache-restore @@ -101,31 +91,33 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-cargo-stable-${{ steps.rust-cache-key.outputs.version }}-${{ hashFiles('Cargo.lock') }} + key: ${{ runner.os }}-cargo-stable-${{ steps.rust-cache-key.outputs.version }}-${{ steps.rust-cache-key.outputs.target }}-retrieval-contracts-default-features-${{ hashFiles('Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-stable-${{ steps.rust-cache-key.outputs.version }}- - ${{ runner.os }}-cargo-stable- + ${{ runner.os }}-cargo-stable-${{ steps.rust-cache-key.outputs.version }}-${{ steps.rust-cache-key.outputs.target }}-retrieval-contracts-default-features- + + - name: Generalization lint regression contracts + run: cargo test --locked -p codestory-runtime --test retrieval_generalization_guard - name: Runtime sidecar and packet contract tests run: | - cargo test -p codestory-runtime --lib agent::retrieval_primary::tests - cargo test -p codestory-runtime --lib agent::packet_search::tests - cargo test -p codestory-runtime --lib agent::packet_claim_profiles::tests - cargo test -p codestory-runtime --lib search_rejects_natural_language_queries_without_full_sidecars - cargo test -p codestory-runtime --lib search_results_ignores_repo_text_hits_without_full_sidecars - cargo test -p codestory-runtime --lib repo_text_auto_fallback_is_not_product_search_without_full_sidecars + cargo test --locked -p codestory-runtime --lib agent::retrieval_primary::tests + cargo test --locked -p codestory-runtime --lib agent::packet_search::tests + cargo test --locked -p codestory-runtime --lib agent::packet_claim_profiles::tests + cargo test --locked -p codestory-runtime --lib search_rejects_natural_language_queries_without_full_sidecars + cargo test --locked -p codestory-runtime --lib search_results_ignores_repo_text_hits_without_full_sidecars + cargo test --locked -p codestory-runtime --lib repo_text_auto_fallback_is_not_product_search_without_full_sidecars - name: Stdio protocol contract tests - run: cargo test -p codestory-cli --test stdio_protocol_contracts + run: cargo test --locked -p codestory-cli --test stdio_protocol_contracts - name: CLI search JSON fail-closed contract tests - run: cargo test -p codestory-cli --test search_json_output + run: cargo test --locked -p codestory-cli --test search_json_output - name: Packet/search fixture and baseline contract tests - run: cargo test -p codestory-cli --test packet_search_eval + run: cargo test --locked -p codestory-cli --test packet_search_eval - name: Retrieval crate unit tests - run: cargo test -p codestory-retrieval + run: cargo test --locked -p codestory-retrieval - name: Save Cargo registry, git sources, and build output if: always() && steps.cargo-cache-restore.outputs.cache-hit != 'true' && steps.cargo-cache-restore.outputs.cache-primary-key != '' @@ -139,7 +131,7 @@ jobs: key: ${{ steps.cargo-cache-restore.outputs.cache-primary-key }} windows-manifest-missing: - if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'ci:windows-smoke') + if: github.event_name == 'workflow_dispatch' runs-on: windows-latest timeout-minutes: 30 steps: @@ -154,7 +146,9 @@ jobs: id: rust-cache-key run: | $release = rustc -Vv | Select-String '^release: ' | ForEach-Object { $_.ToString().Substring(9) } + $target = rustc -Vv | Select-String '^host: ' | ForEach-Object { $_.ToString().Substring(6) } "version=$release" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "target=$target" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - name: Restore Cargo registry, git sources, and build output id: cargo-cache-restore @@ -165,13 +159,12 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-cargo-stable-${{ steps.rust-cache-key.outputs.version }}-${{ hashFiles('Cargo.lock') }} + key: ${{ runner.os }}-cargo-stable-${{ steps.rust-cache-key.outputs.version }}-${{ steps.rust-cache-key.outputs.target }}-retrieval-bootstrap-default-features-${{ hashFiles('Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-stable-${{ steps.rust-cache-key.outputs.version }}- - ${{ runner.os }}-cargo-stable- + ${{ runner.os }}-cargo-stable-${{ steps.rust-cache-key.outputs.version }}-${{ steps.rust-cache-key.outputs.target }}-retrieval-bootstrap-default-features- - name: Retrieval bootstrap manifest-missing contract - run: cargo test -p codestory-cli --test retrieval_bootstrap_contracts + run: cargo test --locked -p codestory-cli --test retrieval_bootstrap_contracts - name: Save Cargo registry, git sources, and build output if: always() && steps.cargo-cache-restore.outputs.cache-hit != 'true' && steps.cargo-cache-restore.outputs.cache-primary-key != '' diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index da6a6e54..b4a520c0 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -1,4 +1,4 @@ -name: rust ci +name: Draft source checks on: pull_request: @@ -6,20 +6,11 @@ on: - Cargo.lock - Cargo.toml - crates/** - - docs/contributors/testing-matrix.md - - .github/scripts/check-workflow-policy.mjs - - .github/workflows/rust-ci.yml - push: - branches: - - main - - dev/codestory-next - paths: - - Cargo.lock - - Cargo.toml - - crates/** - - docs/contributors/testing-matrix.md + - .github/scripts/check-runtime-config-boundary.mjs - .github/scripts/check-workflow-policy.mjs + - .github/scripts/route-ci-proof.mjs - .github/workflows/rust-ci.yml + - .github/workflows/source-proof.yml workflow_dispatch: permissions: @@ -30,8 +21,8 @@ concurrency: cancel-in-progress: true jobs: - workspace: - name: workspace cargo checks + linux-draft: + name: Ubuntu draft source checks runs-on: ubuntu-latest timeout-minutes: 45 steps: @@ -42,13 +33,14 @@ jobs: rustup toolchain install stable --profile minimal --component clippy --component rustfmt rustup default stable - - name: Capture Rust cache key + - name: Capture Rust cache identity id: rust-cache-key shell: bash run: | echo "version=$(rustc -Vv | sed -n 's/^release: //p')" >> "$GITHUB_OUTPUT" + echo "target=$(rustc -Vv | sed -n 's/^host: //p')" >> "$GITHUB_OUTPUT" - - name: Restore Cargo registry, git sources, and build output + - name: Restore Cargo inputs and output id: cargo-cache-restore uses: actions/cache/restore@v5 continue-on-error: true @@ -57,25 +49,29 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-cargo-rust-ci-stable-${{ steps.rust-cache-key.outputs.version }}-${{ hashFiles('Cargo.lock') }} + key: ${{ runner.os }}-draft-${{ steps.rust-cache-key.outputs.version }}-${{ steps.rust-cache-key.outputs.target }}-workspace-default-features-${{ hashFiles('Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-rust-ci-stable-${{ steps.rust-cache-key.outputs.version }}- - ${{ runner.os }}-cargo-stable-${{ steps.rust-cache-key.outputs.version }}- - ${{ runner.os }}-cargo-stable- + ${{ runner.os }}-draft-${{ steps.rust-cache-key.outputs.version }}-${{ steps.rust-cache-key.outputs.target }}-workspace-default-features- - name: Check formatting run: cargo fmt --check - - name: Check workspace + - name: Check immutable runtime configuration boundary + run: node .github/scripts/check-runtime-config-boundary.mjs + + - name: Check the workspace run: cargo check --workspace --locked - - name: Test workspace - run: cargo test --locked + - name: Lint workspace libraries + run: cargo clippy --workspace --lib --locked -- -D warnings - - name: Lint workspace - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - name: Prove focused publication contracts + run: | + cargo test --locked -p codestory-cli --test stdio_protocol_contracts two_stdio_processes_observe_only_complete_generations_during_real_refresh -- --nocapture + cargo test --locked -p codestory-runtime publication_transitions_fail_or_cancel_atomically -- --nocapture + cargo test --locked -p codestory-store staged_promotion_abort_recovers_old_or_complete_new_and_cleans_artifacts -- --nocapture - - name: Save Cargo registry, git sources, and build output + - name: Save Cargo inputs and output if: always() && steps.cargo-cache-restore.outputs.cache-hit != 'true' && steps.cargo-cache-restore.outputs.cache-primary-key != '' uses: actions/cache/save@v5 continue-on-error: true diff --git a/.github/workflows/rustdoc.yml b/.github/workflows/rustdoc.yml index b7666a56..270f29c1 100644 --- a/.github/workflows/rustdoc.yml +++ b/.github/workflows/rustdoc.yml @@ -48,6 +48,7 @@ jobs: shell: bash run: | echo "version=$(rustc -Vv | sed -n 's/^release: //p')" >> "$GITHUB_OUTPUT" + echo "target=$(rustc -Vv | sed -n 's/^host: //p')" >> "$GITHUB_OUTPUT" - name: Restore Cargo registry, git sources, and build output id: cargo-cache-restore @@ -58,15 +59,14 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-rustdoc-stable-${{ steps.rust-cache-key.outputs.version }}-${{ hashFiles('Cargo.lock') }} + key: ${{ runner.os }}-rustdoc-stable-${{ steps.rust-cache-key.outputs.version }}-${{ steps.rust-cache-key.outputs.target }}-workspace-doc-default-features-${{ hashFiles('Cargo.lock') }} restore-keys: | - ${{ runner.os }}-rustdoc-stable-${{ steps.rust-cache-key.outputs.version }}- - ${{ runner.os }}-rustdoc-stable- + ${{ runner.os }}-rustdoc-stable-${{ steps.rust-cache-key.outputs.version }}-${{ steps.rust-cache-key.outputs.target }}-workspace-doc-default-features- - name: Deny rustdoc warnings env: RUSTDOCFLAGS: -D warnings - run: cargo doc --workspace --no-deps + run: cargo doc --workspace --no-deps --locked - name: Save Cargo registry, git sources, and build output if: always() && steps.cargo-cache-restore.outputs.cache-hit != 'true' && steps.cargo-cache-restore.outputs.cache-primary-key != '' diff --git a/.github/workflows/saga-issue-link-guard.yml b/.github/workflows/saga-issue-link-guard.yml index 6c607120..a4b91dd7 100644 --- a/.github/workflows/saga-issue-link-guard.yml +++ b/.github/workflows/saga-issue-link-guard.yml @@ -8,6 +8,10 @@ permissions: contents: read pull-requests: read +concurrency: + group: saga-issue-link-guard-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: require-closing-issue-link: name: require closing issue link diff --git a/.github/workflows/source-proof.yml b/.github/workflows/source-proof.yml new file mode 100644 index 00000000..d2bf0250 --- /dev/null +++ b/.github/workflows/source-proof.yml @@ -0,0 +1,135 @@ +name: Exact-head source proof + +on: + pull_request: + types: [labeled, synchronize] + workflow_call: + inputs: + ref: + required: true + type: string + proof_key: + required: true + type: string + workflow_dispatch: + inputs: + pr_number: + description: Same-repository pull request accepted by exact-head review. + required: true + type: string + expected_head_sha: + description: Exact reviewed head SHA. The gate refuses a newer or older head. + required: true + type: string + +permissions: + contents: read + pull-requests: read + +concurrency: + group: source-proof-${{ inputs.proof_key || inputs.pr_number || github.event.pull_request.number || github.ref }}-${{ github.event.action == 'labeled' && github.event.label.name != 'review-accepted' && github.event.label.name || 'candidate' }} + cancel-in-progress: true + +jobs: + resolve: + if: github.event_name != 'pull_request' || (github.event.action == 'labeled' && github.event.label.name == 'review-accepted') + runs-on: ubuntu-latest + outputs: + ref: ${{ steps.resolve.outputs.ref }} + steps: + - name: Resolve trusted exact head + id: resolve + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }} + CALLER_REF: ${{ inputs.ref }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + run: | + set -euo pipefail + if [ -n "$EVENT_PR_NUMBER" ]; then + test "$EVENT_HEAD_REPO" = "$GITHUB_REPOSITORY" || { + echo "::error::Exact-head proof does not execute fork pull requests." + exit 1 + } + pr="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$EVENT_PR_NUMBER")" + current_head="$(jq -r '.head.sha' <<<"$pr")" + test "$current_head" = "$EVENT_HEAD_SHA" || { + echo "::error::PR #$EVENT_PR_NUMBER moved after the review-accepted label event." + exit 1 + } + echo "ref=$EVENT_HEAD_SHA" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ -n "$PR_NUMBER" ]; then + pr="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")" + head_repo="$(jq -r '.head.repo.full_name' <<<"$pr")" + head_sha="$(jq -r '.head.sha' <<<"$pr")" + test "$head_repo" = "$GITHUB_REPOSITORY" || { + echo "::error::Exact-head proof does not execute fork pull requests." + exit 1 + } + test "$head_sha" = "$EXPECTED_HEAD_SHA" || { + echo "::error::PR #$PR_NUMBER moved from reviewed head $EXPECTED_HEAD_SHA to $head_sha." + exit 1 + } + echo "ref=$head_sha" >> "$GITHUB_OUTPUT" + else + test -n "$CALLER_REF" + echo "ref=$CALLER_REF" >> "$GITHUB_OUTPUT" + fi + + full-source-gate: + name: full-source-gate + needs: resolve + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.resolve.outputs.ref }} + + - name: Install Rust stable + run: | + rustup toolchain install stable --profile minimal --component clippy + rustup default stable + + - name: Capture Rust cache identity + id: rust-cache-key + shell: bash + run: | + echo "version=$(rustc -Vv | sed -n 's/^release: //p')" >> "$GITHUB_OUTPUT" + echo "target=$(rustc -Vv | sed -n 's/^host: //p')" >> "$GITHUB_OUTPUT" + + - name: Restore Cargo inputs and output + id: cargo-cache-restore + uses: actions/cache/restore@v5 + continue-on-error: true + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-source-proof-${{ steps.rust-cache-key.outputs.version }}-${{ steps.rust-cache-key.outputs.target }}-workspace-all-targets-all-features-${{ hashFiles('Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-source-proof-${{ steps.rust-cache-key.outputs.version }}-${{ steps.rust-cache-key.outputs.target }}-workspace-all-targets-all-features- + + - name: Test the complete workspace once + run: cargo test --workspace --locked + + - name: Lint every workspace target and feature once + run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + + - name: Save Cargo inputs and output + if: always() && steps.cargo-cache-restore.outputs.cache-hit != 'true' && steps.cargo-cache-restore.outputs.cache-primary-key != '' + uses: actions/cache/save@v5 + continue-on-error: true + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ steps.cargo-cache-restore.outputs.cache-primary-key }} diff --git a/AGENTS.md b/AGENTS.md index e0f07063..173fe1c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,96 +1,240 @@ -# Repository Guidelines - -**Audience:** Contributors — workspace layout, verification lanes, and merge bar. - -## Project Structure & Module Organization -- Rust workspace is defined in `Cargo.toml`; crates live under `crates/`. -- Primary runtime surface is `crates/codestory-cli`; the canonical agent skill ships with the plugin under `plugins/codestory/skills/codestory-grounding`. -- Workspace crates: `codestory-contracts`, `codestory-workspace`, `codestory-store`, `codestory-indexer`, `codestory-retrieval`, `codestory-runtime`, `codestory-cli`, `codestory-bench`. -- Runtime artifacts: user-cache SQLite grounding indexes keyed by repo path; build outputs in `target/`. - -## Architecture Overview -- `codestory-runtime` is the headless orchestrator used by the CLI and skill scripts. -- `codestory-contracts` holds the shared graph model, DTOs, grounding/trail types, and shared events. -- Indexing pipeline: `codestory-workspace` discovers files and refresh plans, `codestory-indexer` extracts symbols/edges via tree-sitter + semantic resolution, and `codestory-store` persists graph/search/snapshot state to SQLite. -- `codestory-bench` contains Criterion benches for indexing, grounding, resolution, and cleanup work. - -## Build, Test, and Development Commands -- Backend build/test: `cargo build`, `cargo test`, `cargo check`, `cargo fmt`, `cargo clippy`. -- CLI runtime: `cargo run --release -p codestory-cli -- index --project .`. -- Skill-first grounding: `cargo run --release -p codestory-cli -- ground --project .`. -- Codex worktree setup runs `scripts/codex-worktree-setup.ps1` before the thread - starts: it resolves a ready CLI from `CODESTORY_CLI`, PATH, this worktree, or a - sibling worktree before building with `sccache`; then it tries `cache - rehydrate`, refreshes the SQLite index, and best-effort refreshes retrieval - sidecars/status. -- Release version checks: `python .github/scripts/check-codestory-release.py --version ` and `node .github/scripts/check-workflow-policy.mjs`. -- On Windows, the Codex npm shim should be invoked as `codex.cmd` (typically under `%APPDATA%\\npm`); using the extensionless `codex` shim can fail with `os error 193`. -- In this PowerShell environment, large parallel file reads can truncate output; when investigating a single large file, prefer one direct read command (for example `Get-Content` or `cmd /c type`) before parallelizing. -- Public GitHub status comments should go through `node scripts/github-status-comment.mjs --issue --body-file ` or stdin; the helper rejects literal `\\n` text before posting. - -## Coding Style & Naming Conventions -- Rust edition is `2024` across workspace crates. -- Rust naming: `snake_case` for functions/modules, `PascalCase` for types, `SCREAMING_SNAKE_CASE` for constants. - -## Testing Guidelines -- Tests live in `#[cfg(test)]` blocks or `*_tests.rs`; name them `test_*`. -- Before committing, run the repo-scale CLI e2e stats test and append the emitted stats to `docs/testing/codestory-e2e-stats-log.md`: - - `cargo build --release -p codestory-cli` - - `cargo test -p codestory-cli --test codestory_repo_e2e_stats -- --ignored --nocapture` -- To exercise indexing fidelity and coverage, you must explicitly run full test binaries, not just filters: +# CodeStory Agent Guide + +**Audience:** agents and contributors changing CodeStory. This file contains the +decisions that must remain visible while working; generated help, architecture +pages, runbooks, and workflows own detailed mechanics. + +## Start Here + +- Establish current truth before choosing work: inspect the current branch and + integration head, active worktrees, open PR or issue ownership, and the + release state. Do not reuse an active lane or implement routine work directly + on `dev/codestory-next`. +- Run `node scripts/codex-worktree-setup.mjs` for a delegated worktree. Treat + its printed base, child head, PR head, and proof target as authoritative + before cache repair, readiness work, or verification. When changing setup, + keep the PowerShell and POSIX implementations behaviorally aligned and run + the setup self-tests from the testing matrix. +- Before source claims, planning edits, choosing tests, or reviewing changes, + use the canonical CodeStory grounding skill when its MCP tools are visible. + Every MCP call must carry the target repository's absolute `project` root. + Reuse current status until repository, runtime, or index state changes. +- Obey `allowed_surfaces` and `retrieval_mode`. Use `packet`, `search`, or + `context` only when that surface is allowed and retrieval is `full`. If the + MCP tools are not visible, use ordinary source inspection and report the + visibility gap. CLI diagnostics do not prove that the packaged plugin MCP is + live in the agent host. +- For a large change, read `docs/architecture/overview.md`, the owning + subsystem page, `docs/contributors/debugging.md`, and + `docs/contributors/testing-matrix.md` before editing. + +## Ownership Boundaries + +- `codestory-contracts`: shared DTOs, graph types, events, grounding and trail + contracts. +- `codestory-workspace`: project discovery, inventories, refresh planning, and + repository/project identity. +- `codestory-indexer`: parsing, extraction, intermediate projections, and + semantic resolution. +- `codestory-store`: SQLite source of truth, snapshots, projections, and core + publication. +- `codestory-retrieval`: lexical, semantic, and SCIP artifacts; immutable + sidecar generations; manifests; health; and fail-closed query execution. +- `codestory-runtime`: the only product orchestration layer. Indexing, + grounding, search, packet construction, and agent flows belong here. +- `codestory-cli`: command and transport parsing, output rendering, process + configuration capture, readiness-broker integration, and managed sidecar + lifecycle boundaries. Do not move product orchestration into adapters. +- `plugins/codestory`: host hooks, the packaged launcher, MCP routing, and the + canonical agent skill. Plugin routing selects a project per request and + reaches product behavior through the version-matched CLI. +- `codestory-bench`: measurement and benchmark support only; it does not own + product contracts. + +Dependency direction is +`contracts -> workspace/store/indexer/retrieval -> runtime -> cli/adapters`. +Change the owning source-of-truth layer first; do not patch a derived view or +adapter to compensate for incorrect upstream state. + +## Product Invariants + +### Identity and configuration + +- Keep logical project, workspace, artifact scope, publication generation, + task/request, run, lease, and process identity distinct. Never replace these + contracts with a path spelling, PID, mutable environment variable, or global + active-project value. +- Every MCP or plugin request selects its project explicitly. Hook-written + active-state files are diagnostic only and must not route a runtime. +- Compare existing paths and executables by native filesystem identity. Use + platform lexical rules only for missing paths; Unix path equality remains + case-sensitive and Windows path equality case-insensitive. +- Capture user home, network trust, cache root, and runtime defaults once at + process start. Retain immutable configuration per project. Switching projects + must not mutate or re-read the process environment, and project config must + not silently choose cache roots, credentials, or network-egress endpoints. + +### Reads, activation, and publication + +- Status, doctor, and other read surfaces are observational. They must not + download assets, refresh indexes, start repair, or mutate sidecar state. + Grounding and explicit project-scoped setup or repair operations own + activation. +- Writers stage and validate a complete generation before publishing it. + Current and rollback pointers change atomically; readers pin one complete + old-or-new generation. Failure, cancellation, or concurrent source drift + leaves the previous publication usable and schedules or reports a retry. +- Freshness depends on verified source/content and publication identity, not + timestamps alone. Partial, unreadable, or bounded discovery cannot prove + absence and must never schedule deletion. +- Cleanup may remove only resources proven CodeStory-owned by a current token, + lease, manifest, generation, or proof marker. Do not perform broad Docker, + process, port, or user-cache cleanup. +- Agent-facing packet/search/context must fail closed on stale or partial + publications, ambiguous migration, changed runtime identity, non-`full` + sidecars, dead required infrastructure, or missing required accelerator/embed + proof. `retrieval_mode=full` proves infrastructure eligibility, not answer + quality or claim sufficiency. + +### Evaluation and surface boundaries + +- Production packet/search behavior must not contain holdout repository names, + fixture paths, expected-answer shapes, or benchmark-family steering. +- Benchmark-shaped probe catalogs and claim/source-truth scoring stay behind + test-only evaluation boundaries. +- Language claims must name their tier: parser-backed graph coverage, + structural source collectors, or agent-facing packet quality. +- `packet` owns broad retrieval. `drill` adapts that packet path and must not + create a second search, readiness, bridge, or scoring system. +- Browser and HTTP adapters remain read-only and loopback-bound by default. + Any broader browser, UI, or network surface must satisfy the browser surface + gate in the testing documentation. +- Generated CLI help is the option source of truth. Keep user docs + workflow-oriented instead of duplicating complete flag matrices. + +## Verification By Change + +- Choose the smallest credible lane from + `docs/contributors/testing-matrix.md` before running broad checks. Run + separate Cargo build, check, test, and clippy commands serially because this + workspace shares build locks. Use locked dependency resolution in proof + lanes. +- Do not use `cargo test --workspace --all-targets` as the routine broad gate; + it expands Criterion targets. Draft work uses focused checks. The full + workspace test and all-target/all-feature clippy gate run once on an + independently accepted exact head. +- CLI integration tests must launch through + `tests/test_support::cli_command` or its supplied-binary variant, use + isolated cache/install/plugin state roots, and drain spawned repair workers. + Never clean or write the real user cache to make a test pass, and never + serialize the suite to hide state leakage. +- Docs-only scope is `README.md`, `docs/**`, `plugins/codestory/README.md`, + `plugins/codestory/docs/**`, and `plugins/codestory/skills/**`. Read changed + pages back, then run `git diff --check` and + `node .github/scripts/check-doc-links.mjs`. Do not add tests that assert prose. + Plugin adapter changes also run + `node --test plugins/codestory/tests/plugin-static.test.mjs`. +- Indexer fidelity or language coverage requires the full binaries, not name + filters: - `cargo test -p codestory-indexer --test fidelity_regression` - `cargo test -p codestory-indexer --test tictactoe_language_coverage` - - Note: Using `cargo test -p codestory-index fidelity_regression` will just filter tests instead of running the targeted suites. -- Cargo verifications (build, check, test) should be serialized when working in this repo because parallel `cargo` commands will contend on the shared package and build locks. -- For graph perf and fidelity checks, use Criterion benches in `crates/codestory-bench`. -- Operator documentation lives in `docs/users/`. Docs-only proof: `git diff --check` and `node .github/scripts/check-doc-links.mjs`. Do not add unit tests that assert documentation copy or required phrases. - -## Commit & Pull Request Guidelines -- Commit messages are short, lowercase, imperative (e.g., `fix minimap`, `refactor graph style`). -- Before staging or committing, update `CHANGELOG.md` whenever the diff changes - shipped behavior, operator guidance, release automation, packaging, or version - metadata. If the work modifies the current latest unreleased version or - creates the next latest version heading, keep the entry under `Unreleased` or - that new release heading in the same commit. -- PRs should include a summary, tests run, linked issues, and relevant artifacts for behavior changes. -- Routine implementation PRs branch from and target `dev/codestory-next`. Do not target `main` directly unless the lane is an explicit release, hotfix, final comparison/review artifact, or promotion. -- Agent branches should use the `codex/` prefix by default. Saga comparison/review branches may use `review/codestory-saga-*` when the branch exists only to support a reviewer comparison. -- The saga issue-link guard applies to `codex/*` heads, `review/codestory-saga-*` heads, `[codex]` PR titles, and PRs labeled `saga:codestory-intelligence`. Guarded PRs must include a closing issue reference (`Closes #123`, `Fixes #123`, `Resolves #123`, or the full GitHub issue URL) for the PR-sized issue. Use `Refs #...` only for broader parents or related context. For PRs targeting `dev/codestory-next`, add both the issue and PR to the Project because GitHub's computed Linked pull requests field may not populate until default-branch promotion. -- If a slice is partial under a larger saga, create or use a PR-sized child issue and close that issue in the PR body; do not close the parent until its acceptance criteria are actually met. -- Keep release comparisons, ledgers, and generated pre-release evidence in PR bodies, issue comments, project updates, or external artifacts. Do not commit generated comparison docs/CSVs/SVGs into the repo unless they are intended to become durable product documentation. - -## Release Guidelines -- `crates/codestory-cli/Cargo.toml` is the release version source. -- Update every `codestory-*` workspace crate version and `Cargo.lock` together. -- Before committing a change that modifies the current unreleased version or - creates the next latest version, update `CHANGELOG.md` under `Unreleased` or - the new release heading so release contents are tracked while the work is - still reviewable. -- Do not create or push `v*` release tags manually. A synchronized version bump on `main` triggers GitHub Actions to create the tag, GitHub release, cross-platform `codestory-cli` binary assets, and `SHA256SUMS.txt`. -- After merging a `dev/codestory-next` promotion PR into `main`, verify - `dev/codestory-next` still exists and matches `main` with - `git ls-remote --heads origin main dev/codestory-next` and - `git rev-list --left-right --count origin/main...origin/dev/codestory-next`. - If GitHub deletes the head branch, restore it from the promoted `main` commit - before treating the release as complete. -- After a plugin release or plugin-source update that Codex must detect through - the marketplace, push a corresponding change to - `TheGreenCedar/AgentPluginMarketplace`; Codex observes the marketplace repo - state, not only the CodeStory repo release. -- CI binary assets prove build/package smoke only. Packet/search readiness still requires the sidecar evidence tiers in `docs/contributors/testing-matrix.md`. - -## Retrieval documentation -- Canonical sidecar retrieval docs are `docs/architecture/retrieval-design.md`, `docs/testing/retrieval-architecture.md`, and `docs/ops/retrieval-sidecars.md`. Parser compatibility records live in `docs/architecture/language-support.md`. - -## Coding & Design Constraints - -Current merge bar for production changes: - -1. No holdout literals in production paths — packet/search code must not depend on benchmark holdout repo names, fixture paths, or expected-answer shapes. -2. Eval probes stay test-only — benchmark-shaped probe catalogs remain behind the test-only eval-probe boundary. -3. Language support claims match claim tier definitions — distinguish parser-backed graph coverage, structural collectors, and agent-facing packet quality. -4. Benchmark assertions reference living stats, not hard-coded baselines — repo-scale timing belongs in `docs/testing/codestory-e2e-stats-log.md`. -5. Retrieval mode changes require sidecar evidence — agent packet/search readiness must report full sidecar retrieval, not semantic-only fallback. - -## Security & Configuration Tips -- Keep secrets out of the repo; pass credentials via environment variables. +- Publication, identity, packaging, or platform changes require their named + concurrency, fault, package, and native proof lanes from the testing matrix. + Draft CI, exact-head source proof, platform proof, and integration proof are + distinct stages. A persistent label never authorizes an unreviewed later SHA. +- Run the repo-scale CLI stats lane once on the promoted final merge-ready head + only when default indexing, symbol/dense persistence, embedding reuse, or + cold-start behavior changed. Intermediate commits do not append telemetry. + `docs/testing/codestory-e2e-stats-log.md` is telemetry only and cannot + authorize a release; release-significant decisions use the approved, + attested profile and `scripts/codestory-release-evidence-gate.mjs`. + +## Git, PR, and Evidence Workflow + +- Routine implementation branches start from and target + `dev/codestory-next`. Agent branches use `codex/` by default. Comparison-only + reviewer branches may use `review/codestory-saga-*`. +- Every PR into `main` must come from the same repository's + `dev/codestory-next` branch. Release, promotion, hotfix, and review work does + not bypass this source-branch guard. +- Guarded PRs (`codex/*`, `review/codestory-saga-*`, `[codex]` titles, or the + saga label) must close a PR-sized issue with `Closes`, `Fixes`, or `Resolves`. + Use `Refs` for broader parents. A partial slice closes only its child issue; + keep the parent open until its acceptance criteria are met. +- For PRs targeting `dev/codestory-next`, add both the issue and PR to the + Project; computed linked-PR fields may not populate before default-branch + promotion. +- Keep active ownership visible through the issue/PR lane: worktree, branch, + base SHA, current head, role, checks, blockers, and next proof target. Keep + PRs draft through implementation, independent review, exact-head re-review, + and required CI. Ready means mergeable now. +- PRs should explain context, what changed, how to review, verification, risk, + and follow-up. Include exact SHAs and distinguish completed proof from + non-claims. +- Public GitHub status comments must use + `node scripts/github-status-comment.mjs --issue --body-file ` or + stdin; the helper rejects literal `\\n` text. +- Keep generated comparisons, ledgers, CSVs, SVGs, and pre-release evidence in + PR bodies, issue comments, Project updates, CI artifacts, or external + storage unless they are intended as durable product documentation. +- Update `CHANGELOG.md` in the same change when shipped behavior, operator or + contributor guidance, release automation, packaging, or version metadata + changes. Keep current work under `Unreleased` until release preparation. +- Commit messages are short, lowercase, and imperative. + +## Release Rules + +- `crates/codestory-cli/Cargo.toml` is the release version source. Synchronize + every `codestory-*` workspace crate, `Cargo.lock`, and these plugin manifests: + - `plugins/codestory/.codex-plugin/plugin.json` + - `plugins/codestory/.claude-plugin/plugin.json` + - `plugins/codestory/.github/plugin/plugin.json` +- Validate release changes with + `python .github/scripts/check-codestory-release.py --version ` and + `node .github/scripts/check-workflow-policy.mjs`. +- Never create or push `v*` tags manually. A synchronized version bump on + `main` triggers the release workflow that creates the tag, GitHub release, + native archives, and `SHA256SUMS.txt`. +- Source checks, built binaries, packaged archives, installed plugin launchers, + fresh host sessions, and live full-sidecar behavior are distinct proof tiers. + A lower tier cannot support a higher-tier claim. Use the named testing-matrix + lane for packet/search readiness, accelerator execution, signing/notarization + and Gatekeeper, restart survival, host visibility, or another architecture. +- After promoting `dev/codestory-next` into `main`, verify the dev branch still + exists and matches `main` with: + - `git ls-remote --heads origin main dev/codestory-next` + - `git rev-list --left-right --count origin/main...origin/dev/codestory-next` + Restore a deleted dev branch from the promoted `main` commit before declaring + the release complete. +- Release closeout includes the required source, package, native, protected + hardware, post-publish, installed-runtime, and live behavior evidence for the + claims being shipped. A merge, tag, or downloadable archive alone is not + release completion. +- When Codex must observe a plugin-source change, publish the corresponding + update to `TheGreenCedar/AgentPluginMarketplace`, refresh the marketplace, + and verify the installed managed runtime path/version plus project-scoped + status. CodeStory repository state alone does not update the marketplace. + +## Platform and Security Notes + +- On Windows, invoke the Codex npm shim as `codex.cmd`; the extensionless shim + can fail with `os error 193`. +- Keep secrets out of the repository. Pass credentials through approved + environment or protected CI secret surfaces, and keep private material out + of logs, fixtures, generated artifacts, and comments. + +## Canonical References + +- Architecture and ownership: `docs/architecture/overview.md` and + `docs/architecture/subsystems/` +- Contributor setup and debugging: `docs/contributors/getting-started.md` and + `docs/contributors/debugging.md` +- Verification, CI maturity, release proof, and evidence tiers: + `docs/contributors/testing-matrix.md` +- Retrieval design and operations: `docs/architecture/retrieval-design.md`, + `docs/testing/retrieval-architecture.md`, and + `docs/ops/retrieval-sidecars.md` +- Language claim tiers: `docs/architecture/language-support.md` +- Agent operational contract: + `plugins/codestory/skills/codestory-grounding/SKILL.md` and its references +- Current CLI syntax: `codestory-cli --help` and subcommand help diff --git a/CHANGELOG.md b/CHANGELOG.md index 1367b503..3725bfb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,94 @@ # Changelog -## Unreleased +## 0.15.0 + +### Highlights + +- Agents now have one managed path from opening a project through indexing, + packet construction, and search. Fresh plugin sessions can show diagnostics + while the matching CLI is installed, then bring stale projects current + through normal grounding instead of a separate repair command. +- Index and retrieval publication is atomic. Readers stay on one complete + generation, failed or interrupted refreshes leave the previous generation + usable, and source changes during a refresh cannot publish stale results. +- Packet, search, and context now fail closed when their publication, sidecars, + embedding endpoint, process identity, or required accelerator proof is stale + or ambiguous. Local graph navigation remains available when only deep search + is warming or degraded. +- Every plugin request selects its project explicitly. Switching repositories + cannot inherit another project's cache, endpoint, operation, or readiness + evidence, including on case-sensitive Unix filesystems. +- Grounding is more precise for request flows, configuration files, framework + routes, and compact executable paths. Diagnostic file hits no longer hide + resolved graph evidence, while real source gaps still prevent an unsupported + sufficiency claim. + +### macOS + +- macOS 15 and newer is a supported native target. Apple Silicon uses the + checksum-pinned managed embedding server with Metal. CodeStory verifies the + selected endpoint, process identity, GPU offload, and a timed embedding + request before opening agent retrieval. +- Intel Macs support the native CLI, managed plugin, local graph, and grounding. + Semantic retrieval requires explicit CPU/external policy; that policy may + select a trusted external embedding endpoint. Intel status and evidence never + claim Metal acceleration. +- Managed readiness follows the selected dynamic endpoint, reuses one matching + live embedding process when projects switch, and blocks packet and search + with repair guidance if the endpoint or verified accelerator identity + disappears or changes. +- Direct-download Mac artifacts are release-ready only after the exact arm64 and + x64 binaries pass Developer ID signing, notarization, quarantined extraction, + Gatekeeper, and native execution checks. Source or unsigned package checks do + not make that claim. + +### Reliability and operations + +- The legacy ONNX backend and its runtime, installer, setup, configuration, and + environment selections have been removed. Use managed llama.cpp under the + host's configured accelerator/CPU policy, or a trusted external endpoint + under explicit CPU/external policy. Stale ONNX settings now fail with + migration guidance instead of being silently ignored. +- Existing retrieval rows migrate in place. Ambiguous legacy sidecar or broker + state is retained for bounded inventory and cleanup but is not reused; deep + search may require a managed repair or rebuild after upgrade. +- Incremental indexing verifies file content rather than trusting modification + times alone. Retrieval reads remain pinned to one graph and sidecar + publication, and a concurrent publication change returns a bounded retry + instead of mixing evidence from different generations. +- Lexical search avoids full-corpus validation on every query, and framework + route discovery handles multiline imports, aliases, comments, and shadowing + without repeatedly rescanning a module. +- Status, doctor, and malformed MCP resource reads remain observational: they + do not refresh projects, start repair, or mutate sidecar state. Normal repair + guidance stays in the managed MCP flow, with CLI commands retained for + operator diagnostics. +- Native processes, ports, generations, downloads, and cleanup are tied to + verified CodeStory ownership. Dead or reused processes, conflicting legacy + state, unsafe paths, oversized downloads, and ambiguous cleanup all fail + closed instead of being guessed through. +- The packaged plugin launches the native CodeStory CLI without a shell. On + Windows, `CODESTORY_CLI` must name a native `.exe`; the supported `codex.cmd` + host shim is unchanged. Cross-platform worktree setup uses one + version-checked path on Windows, macOS, and Linux. +- Linux x64 requires glibc 2.31 or newer. Both Linux architectures receive + packaged execution checks; accelerator and full-retrieval claims still + require live sidecar evidence for the selected host. +- Repository-controlled network endpoints remain disabled by default. Setting + `CODESTORY_ALLOW_PROJECT_NETWORK_CONFIG` is an explicit trust decision that + permits the repository to choose summary and embedding egress endpoints. +- Release proof is staged by maturity: focused checks run during development, + while exact-head platform, protected hardware, signing, installation, and + live-runtime evidence run at their promotion or publication boundaries. + +### Release boundary + +- The repository version and release notes describe the candidate source. + Publication is complete only when the matching archives and checksums are + available and the required platform checks pass. +- Marketplace availability, Gatekeeper acceptance, installed runtime/version + readback, and live full-retrieval behavior are separate post-publication + claims and require their own evidence. ## 0.14.3 @@ -11,6 +99,10 @@ handoff, and installer ownership proof. Release notes and contributor guidance now preserve Apple Silicon, Windows arm64 acceleration, older-glibc, marketplace, and full-sidecar proof boundaries. +- Declared glibc 2.31 as the minimum supported Linux x64 userspace and added a + pinned Debian Bullseye build plus Ubuntu 20.04 packaged-archive execution + gate for version, help, and stdio initialization with retained loader and + symbol diagnostics. - Kept strict sidecar readiness fail-closed on interrupted index-run markers without globally rejecting completed generations that contain parser-partial files or repeatedly refreshing unchanged parser-partial inputs; parser diff --git a/Cargo.lock b/Cargo.lock index 48b24ebd..3fd7fcb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,26 +15,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "ahash" -version = "0.8.12" +name = "aho-corasick" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "serde", - "version_check", - "zerocopy", + "memchr", ] [[package]] -name = "aho-corasick" -version = "1.1.4" +name = "aligned" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" dependencies = [ - "memchr", + "as-slice", ] [[package]] @@ -141,6 +136,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -167,24 +171,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bit-set" version = "0.5.3" @@ -455,7 +447,7 @@ checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "codestory-bench" -version = "0.14.3" +version = "0.15.0" dependencies = [ "anyhow", "codestory-contracts", @@ -464,6 +456,7 @@ dependencies = [ "codestory-store", "codestory-workspace", "criterion", + "serde", "serde_json", "tempfile", "uuid", @@ -471,7 +464,7 @@ dependencies = [ [[package]] name = "codestory-cli" -version = "0.14.3" +version = "0.15.0" dependencies = [ "anyhow", "clap", @@ -491,12 +484,12 @@ dependencies = [ "tempfile", "tokio", "toml", - "ureq 2.12.1", + "ureq", ] [[package]] name = "codestory-contracts" -version = "0.14.3" +version = "0.15.0" dependencies = [ "anyhow", "crossbeam-channel", @@ -511,7 +504,7 @@ dependencies = [ [[package]] name = "codestory-indexer" -version = "0.14.3" +version = "0.15.0" dependencies = [ "anyhow", "codestory-contracts", @@ -524,6 +517,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2", "streaming-iterator", "tempfile", "thiserror 2.0.18", @@ -550,7 +544,7 @@ dependencies = [ [[package]] name = "codestory-retrieval" -version = "0.14.3" +version = "0.15.0" dependencies = [ "anyhow", "chrono", @@ -559,6 +553,8 @@ dependencies = [ "codestory-workspace", "directories", "fs4", + "ring", + "rusqlite", "serde", "serde_json", "sha2", @@ -566,12 +562,13 @@ dependencies = [ "thiserror 2.0.18", "tracing", "turbovec", - "ureq 2.12.1", + "ureq", + "uuid", ] [[package]] name = "codestory-runtime" -version = "0.14.3" +version = "0.15.0" dependencies = [ "anyhow", "codestory-contracts", @@ -581,26 +578,25 @@ dependencies = [ "codestory-workspace", "crossbeam-channel", "fs4", - "ndarray", "nucleo-matcher", - "ort", "parking_lot", "rayon", "serde", "serde_json", "tantivy", "tempfile", - "tokenizers", "tracing", - "ureq 2.12.1", + "ureq", + "uuid", ] [[package]] name = "codestory-store" -version = "0.14.3" +version = "0.15.0" dependencies = [ "anyhow", "codestory-contracts", + "fs4", "parking_lot", "rusqlite", "serde", @@ -612,16 +608,21 @@ dependencies = [ [[package]] name = "codestory-workspace" -version = "0.14.3" +version = "0.15.0" dependencies = [ "anyhow", "codestory-contracts", + "fs_at", "glob", "ignore", + "libc", + "remove_dir_all", "serde", "serde_json", + "sha2", "tempfile", "uuid", + "windows-sys 0.59.0", ] [[package]] @@ -647,7 +648,6 @@ dependencies = [ "itoa", "rustversion", "ryu", - "serde", "static_assertions", ] @@ -660,16 +660,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -839,19 +829,12 @@ dependencies = [ ] [[package]] -name = "daachorse" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f55d7153ba3b507595872a3874803f07a8a81d1e888abed8e5db7da0597d6e2" - -[[package]] -name = "darling" -version = "0.20.11" +name = "cvt" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "d2ae9bf77fbf2d39ef573205d554d87e86c12f1994e9ea335b0651b9b278bcf1" dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", + "cfg-if", ] [[package]] @@ -874,20 +857,6 @@ dependencies = [ "darling_macro 0.23.0", ] -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - [[package]] name = "darling_core" version = "0.21.3" @@ -915,17 +884,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.21.3" @@ -948,15 +906,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dary_heap" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" -dependencies = [ - "serde", -] - [[package]] name = "datasketches" version = "0.2.0" @@ -975,16 +924,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" -[[package]] -name = "der" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" -dependencies = [ - "pem-rfc7468", - "zeroize", -] - [[package]] name = "deranged" version = "0.5.8" @@ -995,37 +934,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -1221,12 +1129,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "esaxx-rs" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" - [[package]] name = "euclid" version = "0.22.14" @@ -1258,7 +1160,7 @@ dependencies = [ "num-complex", "num-traits", "paste", - "rand 0.8.6", + "rand", "rand_distr", "rayon", "reborrow", @@ -1377,21 +1279,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1411,6 +1298,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fs_at" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14af6c9694ea25db25baa2a1788703b9e7c6648dcaeeebeb98f7561b5384c036" +dependencies = [ + "aligned", + "cfg-if", + "cvt", + "libc", + "nix", + "windows-sys 0.52.0", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1717,34 +1618,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hmac-sha256" -version = "1.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" - [[package]] name = "htmlescape" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2183,12 +2062,6 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" -[[package]] -name = "lzma-rust2" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" - [[package]] name = "mac_address" version = "1.1.8" @@ -2199,22 +2072,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "macro_rules_attribute" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" -dependencies = [ - "macro_rules_attribute-proc_macro", - "paste", -] - -[[package]] -name = "macro_rules_attribute-proc_macro" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" - [[package]] name = "matrixcompare" version = "0.3.0" @@ -2308,28 +2165,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "monostate" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" -dependencies = [ - "monostate-impl", - "serde", - "serde_core", -] - -[[package]] -name = "monostate-impl" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "murmurhash32" version = "0.3.1" @@ -2348,7 +2183,7 @@ dependencies = [ "num-complex", "num-rational", "num-traits", - "rand 0.8.6", + "rand", "rand_distr", "simba", "typenum", @@ -2435,23 +2270,6 @@ dependencies = [ "nano-gemm-core", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndarray" version = "0.17.2" @@ -2492,6 +2310,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "notify" version = "8.2.0" @@ -2567,7 +2394,7 @@ checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "bytemuck", "num-traits", - "rand 0.8.6", + "rand", ] [[package]] @@ -2643,77 +2470,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" -[[package]] -name = "onig" -version = "6.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" -dependencies = [ - "bitflags 2.13.0", - "libc", - "once_cell", - "onig_sys", -] - -[[package]] -name = "onig_sys" -version = "69.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "oorandom" version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -2738,30 +2500,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "ort" -version = "2.0.0-rc.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" -dependencies = [ - "ndarray", - "ort-sys", - "smallvec", - "tracing", - "ureq 3.3.0", -] - -[[package]] -name = "ort-sys" -version = "2.0.0-rc.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" -dependencies = [ - "hmac-sha256", - "lzma-rust2", - "ureq 3.3.0", -] - [[package]] name = "ownedbytes" version = "0.9.0" @@ -2834,15 +2572,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -2919,7 +2648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand", ] [[package]] @@ -3103,18 +2832,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "rand_chacha", + "rand_core", ] [[package]] @@ -3124,17 +2843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -3146,15 +2855,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_distr" version = "0.4.3" @@ -3162,7 +2862,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand 0.8.6", + "rand", ] [[package]] @@ -3291,17 +2991,6 @@ dependencies = [ "rayon-core", ] -[[package]] -name = "rayon-cond" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" -dependencies = [ - "either", - "itertools 0.14.0", - "rayon", -] - [[package]] name = "rayon-core" version = "1.13.0" @@ -3387,6 +3076,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "remove_dir_all" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cc0b475acf76adf36f08ca49429b12aad9f678cb56143d5b3cb49b9a1dd08" +dependencies = [ + "cfg-if", + "cvt", + "fs_at", + "libc", + "normpath", + "windows-sys 0.59.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -3542,15 +3245,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "schemars" version = "0.9.0" @@ -3581,29 +3275,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.0", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.27" @@ -3674,7 +3345,7 @@ version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "hex", "indexmap 1.9.3", @@ -3802,17 +3473,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socks" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" -dependencies = [ - "byteorder", - "libc", - "winapi", -] - [[package]] name = "specta" version = "2.0.0-rc.22" @@ -3836,18 +3496,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "spm_precompiled" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" -dependencies = [ - "base64 0.13.1", - "nom", - "serde", - "unicode-segmentation", -] - [[package]] name = "sqlite-wasm-rs" version = "0.5.2" @@ -3881,7 +3529,7 @@ dependencies = [ "approx", "nalgebra", "num-traits", - "rand 0.8.6", + "rand", ] [[package]] @@ -3978,7 +3626,7 @@ checksum = "edde6a10743fff00a4e1a8c9ef020bf5f3cbad301b7d2d39f2b07f123c4eac07" dependencies = [ "aho-corasick", "arc-swap", - "base64 0.22.1", + "base64", "bitpacking", "bon", "byteorder", @@ -4172,7 +3820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64 0.22.1", + "base64", "bitflags 2.13.0", "fancy-regex", "filedescriptor", @@ -4309,39 +3957,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "tokenizers" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44e5bea67576e04b6ff8564c5d9e09c2ef0cf476502245f2f120e497769d3112" -dependencies = [ - "ahash", - "compact_str", - "daachorse", - "dary_heap", - "derive_builder", - "esaxx-rs", - "getrandom 0.3.4", - "itertools 0.14.0", - "log", - "macro_rules_attribute", - "monostate", - "onig", - "paste", - "rand 0.9.4", - "rayon", - "rayon-cond", - "regex", - "regex-syntax", - "serde", - "serde_json", - "spm_precompiled", - "thiserror 2.0.18", - "unicode-normalization-alignments", - "unicode-segmentation", - "unicode_categories", -] - [[package]] name = "tokio" version = "1.52.3" @@ -4656,8 +4271,8 @@ dependencies = [ "faer", "ndarray", "ordered-float 4.6.0", - "rand 0.8.6", - "rand_chacha 0.3.1", + "rand", + "rand_chacha", "rand_distr", "rayon", "statrs", @@ -4711,15 +4326,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization-alignments" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" -dependencies = [ - "smallvec", -] - [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -4749,12 +4355,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unicode_categories" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" - [[package]] name = "untrusted" version = "0.9.0" @@ -4767,7 +4367,7 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ - "base64 0.22.1", + "base64", "flate2", "log", "once_cell", @@ -4777,36 +4377,6 @@ dependencies = [ "webpki-roots 0.26.11", ] -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64 0.22.1", - "der", - "log", - "native-tls", - "percent-encoding", - "rustls-pki-types", - "socks", - "ureq-proto", - "utf8-zero", - "webpki-root-certs", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64 0.22.1", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -4825,12 +4395,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5006,15 +4570,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/Cargo.toml b/Cargo.toml index 66173d76..240e2ab0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,14 +44,11 @@ tree-sitter-graph = "0.12" streaming-iterator = "0.1" # Storage -rusqlite = { version = "0.38", features = ["backup", "bundled"] } +rusqlite = { version = "0.38", features = ["backup", "bundled", "hooks"] } # Search nucleo-matcher = "0.3" tantivy = "0.26" -ndarray = "0.17.2" -ort = { version = "=2.0.0-rc.12", features = ["directml"] } -tokenizers = { version = "0.23.1", default-features = false, features = ["onig"] } # Utilities serde = { version = "1.0", features = ["derive"] } @@ -73,6 +70,9 @@ crossbeam-channel = "0.5" crossterm = "0.28" parking_lot = "0.12" ratatui = "0.30" +fs_at = "0.2.1" +libc = "0.2" +remove_dir_all = "1.0.0" uuid = { version = "1.0", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } directories = "6.0.0" @@ -80,8 +80,10 @@ glob = "0.3" tempfile = "3.10" toml = "0.8" sha2 = "0.10" +ring = "0.17.14" ureq = "2.12" turbovec = "0.9.0" +windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] } criterion = "0.8.2" proptest = "1.5" diff --git a/benchmarks/release-evidence/approved-baselines.json b/benchmarks/release-evidence/approved-baselines.json new file mode 100644 index 00000000..744c1b51 --- /dev/null +++ b/benchmarks/release-evidence/approved-baselines.json @@ -0,0 +1,69 @@ +{ + "schema_version": 2, + "profiles": { + "ci-contract-v1": { + "baseline_id": "ci-contract-v1@1111111111111111111111111111111111111111", + "status": "approved", + "release_eligible": false, + "commit": "1111111111111111111111111111111111111111", + "git_state": "clean", + "identity": { + "corpus_id": "ci-contract-corpus-v1", + "cache_id": "cold-full-sidecar-v1", + "machine_fingerprint": "ci-contract/windows-x64/8/test-cpu/16GiB" + }, + "approval": { + "owner": "CodeStory CI contract", + "approved_at": "2026-07-11", + "rationale": "Deterministic checked-in contract fixture only; deliberately ineligible for product release evidence." + }, + "artifacts": [ + { + "path": "fixtures/baseline-raw.json", + "sha256": "045099bc10d243f1f94ccf8a853fdce11fda012c90fe52bbc7302dce373073fc" + } + ], + "metrics": { + "status_seconds": { "reference": 1, "threshold": 1.25, "unit": "seconds", "aggregation": "full-sidecar command wall time", "direction": "max", "source": "baseline-raw.json" }, + "local_grounding_seconds": { "reference": 1, "threshold": 1.25, "unit": "seconds", "aggregation": "full-sidecar command wall time", "direction": "max", "source": "baseline-raw.json" }, + "convergence_seconds": { "reference": 10, "threshold": 11.5, "unit": "seconds", "aggregation": "unchanged-corpus repeat full-refresh wall time", "direction": "max", "source": "baseline-raw.json" }, + "packet_seconds": { "reference": 1, "threshold": 1.1, "unit": "seconds", "aggregation": "worst quality-gated packet median wall time", "direction": "max", "source": "baseline-raw.json" }, + "search_seconds": { "reference": 1, "threshold": 1.25, "unit": "seconds", "aggregation": "full-sidecar command wall time", "direction": "max", "source": "baseline-raw.json" }, + "indexing_seconds": { "reference": 10, "threshold": 12.5, "unit": "seconds", "aggregation": "cold full-refresh wall time", "direction": "max", "source": "baseline-raw.json" }, + "storage_growth_ratio": { "reference": 1, "threshold": 1.1, "reference_bytes": 1000, "unit": "ratio", "aggregation": "candidate bytes divided by approved same-corpus bytes", "direction": "max", "source": "baseline-raw.json" } + } + }, + "codestory-release-evidence-linux-arm64-v1": { + "baseline_id": "codestory-release-evidence-linux-arm64-v1@b09b8c44dc38472e67fc01de81edc66eef38a9be", + "status": "approved", + "release_eligible": true, + "commit": "b09b8c44dc38472e67fc01de81edc66eef38a9be", + "git_state": "clean", + "identity": { + "corpus_id": "codestory-release-corpus-v1", + "cache_id": "cold-full-sidecar-v1", + "machine_fingerprint": "codestory-release-evidence-linux-arm64-v1/b5d4b3f7512ab242f9ed506a8c50d38a8382d07b3d78959287a6981ca84b9156" + }, + "approval": { + "owner": "Albert Najjar", + "approved_at": "2026-07-14", + "rationale": "Protected run 29313560991 on the provisioned Linux ARM64 profile passed full-sidecar stats, resolved all 3 Serde anchors with no blockers, and passed all 9 publishable Axios, Redis, and Ripgrep packet runs. Thresholds use the attested baseline's documented multipliers and rounding rule." + }, + "artifacts": [ + { + "path": "baselines/linux-arm64-v1-b09b8c44.json", + "sha256": "e272b1d08805a7f0f8d6b8189df17eb1fbdfce4c1a47cc75a829be72d18b90e9" + } + ], + "metrics": { + "status_seconds": { "reference": 0.507090389, "threshold": 0.64, "unit": "seconds", "aggregation": "full-sidecar command wall time", "direction": "max", "source": "linux-arm64-v1-b09b8c44.json" }, + "local_grounding_seconds": { "reference": 0.056711914, "threshold": 0.08, "unit": "seconds", "aggregation": "full-sidecar command wall time", "direction": "max", "source": "linux-arm64-v1-b09b8c44.json" }, + "convergence_seconds": { "reference": 14.428549947, "threshold": 16.6, "unit": "seconds", "aggregation": "unchanged-corpus repeat full-refresh wall time", "direction": "max", "source": "linux-arm64-v1-b09b8c44.json" }, + "packet_seconds": { "reference": 5.658959, "threshold": 6.23, "unit": "seconds", "aggregation": "worst quality-gated packet median wall time", "direction": "max", "source": "linux-arm64-v1-b09b8c44.json" }, + "search_seconds": { "reference": 1.51831238, "threshold": 1.9, "unit": "seconds", "aggregation": "full-sidecar command wall time", "direction": "max", "source": "linux-arm64-v1-b09b8c44.json" }, + "indexing_seconds": { "reference": 14.496289933, "threshold": 18.13, "unit": "seconds", "aggregation": "cold full-refresh wall time", "direction": "max", "source": "linux-arm64-v1-b09b8c44.json" }, + "storage_growth_ratio": { "reference": 1, "threshold": 1.1, "reference_bytes": 617172808, "unit": "ratio", "aggregation": "candidate bytes divided by approved same-corpus bytes", "direction": "max", "source": "linux-arm64-v1-b09b8c44.json" } + } + } + } +} diff --git a/benchmarks/release-evidence/baselines/linux-arm64-v1-b09b8c44.json b/benchmarks/release-evidence/baselines/linux-arm64-v1-b09b8c44.json new file mode 100644 index 00000000..f146c0a1 --- /dev/null +++ b/benchmarks/release-evidence/baselines/linux-arm64-v1-b09b8c44.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "commit": "b09b8c44dc38472e67fc01de81edc66eef38a9be", + "evidence_identity": { + "corpus_id": "codestory-release-corpus-v1", + "cache_id": "cold-full-sidecar-v1", + "machine_fingerprint": "codestory-release-evidence-linux-arm64-v1/b5d4b3f7512ab242f9ed506a8c50d38a8382d07b3d78959287a6981ca84b9156" + }, + "source_artifacts": { + "workflow_run_id": 29313560991, + "hosted_artifact": { + "id": 8303155154, + "name": "release-evidence-b09b8c44dc38472e67fc01de81edc66eef38a9be", + "sha256": "ea21bb3a3109d56f7ed26e934c0ba788f3af6f31b3ab67737adf427d60c09460", + "bytes": 198122, + "expires_at": "2026-10-12T07:08:18Z" + }, + "stats": { + "path": "stats.json", + "sha256": "cd759db3e7709aef3eeaf26c804f237b6fb92e9c1095bd50b4f4f37c7417bc34", + "bytes": 5045 + }, + "packet": { + "path": "packet/packet-runtime-summary.json", + "sha256": "b23a9740870c3e3b230fc25a8339edd538d27506d29f2f1f66b6360560bbf773", + "bytes": 281628 + } + }, + "budget_policy": { + "command_seconds_multiplier": 1.25, + "convergence_seconds_multiplier": 1.15, + "packet_seconds_multiplier": 1.1, + "storage_growth_ratio": 1.1, + "seconds_rounding": "ceil to the next 0.01 seconds" + }, + "metrics": { + "status_seconds": 0.507090389, + "local_grounding_seconds": 0.056711914, + "convergence_seconds": 14.428549947, + "packet_seconds": 5.658959, + "search_seconds": 1.51831238, + "indexing_seconds": 14.496289933, + "storage_bytes": 617172808 + } +} diff --git a/benchmarks/release-evidence/fixtures/baseline-raw.json b/benchmarks/release-evidence/fixtures/baseline-raw.json new file mode 100644 index 00000000..dd0a2ae4 --- /dev/null +++ b/benchmarks/release-evidence/fixtures/baseline-raw.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, + "commit": "1111111111111111111111111111111111111111", + "evidence_identity": { + "corpus_id": "ci-contract-corpus-v1", + "cache_id": "cold-full-sidecar-v1", + "machine_fingerprint": "ci-contract/windows-x64/8/test-cpu/16GiB" + }, + "metrics": { + "status_seconds": 1, + "local_grounding_seconds": 1, + "convergence_seconds": 10, + "packet_seconds": 1, + "search_seconds": 1, + "indexing_seconds": 10, + "storage_bytes": 1000 + } +} diff --git a/benchmarks/release-evidence/fixtures/candidate-packet.json b/benchmarks/release-evidence/fixtures/candidate-packet.json new file mode 100644 index 00000000..5682fff7 --- /dev/null +++ b/benchmarks/release-evidence/fixtures/candidate-packet.json @@ -0,0 +1,99 @@ +{ + "schema_version": 1, + "modes": ["cold-cli"], + "repeats": 3, + "release_evidence": { + "commit": "2222222222222222222222222222222222222222", + "profile": "ci-contract-v1", + "evidence_identity": { + "corpus_id": "ci-contract-corpus-v1", + "cache_id": "cold-full-sidecar-v1", + "machine_fingerprint": "ci-contract/windows-x64/8/test-cpu/16GiB" + }, + "publishable": true, + "repeats": 3, + "quality_gate_status": "pass", + "publishable_blockers": [], + "rows": [ + { + "repo": "fixture", + "task_id": "quality-contract", + "mode": "cold_cli_packet", + "repeat": 1, + "status": "pass", + "quality": { "pass": true }, + "sufficiency": { "status": "sufficient", "sufficient_quality_mismatch": false }, + "packet_latency": { "sla_missed": false, "retrieval_shadow": { "retrieval_mode": "full" } }, + "repo_provenance": { + "manifest_overridden_by_builtin": false, + "configured": { "url": "https://github.com/example/fixture.git", "ref": "9999999999999999999999999999999999999999" }, + "manifest": { "url": "https://github.com/example/fixture.git", "ref": "9999999999999999999999999999999999999999" }, + "git_head": "9999999999999999999999999999999999999999", + "git_origin": "https://github.com/example/fixture.git", + "git_dirty": false + }, + "codestory_cache_provenance": { + "doctor_status": "pass", "storage_path": "fixture/codestory.db", + "cache_policy": "prepared-sidecar-cache-read-only", "retrieval_mode": "full", + "sidecar_generation": "fixture-generation", "manifest_embedding_backend": "llamacpp:bge-base-en-v1.5", + "semantic_backend": "llamacpp", "local_only": true, "locality_kind": "local_model_files", + "indexed": true, "freshness_status": "fresh", "semantic_ready": true, "indexing_in_timed_run": false + } + }, + { + "repo": "fixture", + "task_id": "quality-contract", + "mode": "cold_cli_packet", + "repeat": 2, + "status": "pass", + "quality": { "pass": true }, + "sufficiency": { "status": "sufficient", "sufficient_quality_mismatch": false }, + "packet_latency": { "sla_missed": false, "retrieval_shadow": { "retrieval_mode": "full" } }, + "repo_provenance": { + "manifest_overridden_by_builtin": false, + "configured": { "url": "https://github.com/example/fixture.git", "ref": "9999999999999999999999999999999999999999" }, + "manifest": { "url": "https://github.com/example/fixture.git", "ref": "9999999999999999999999999999999999999999" }, + "git_head": "9999999999999999999999999999999999999999", + "git_origin": "https://github.com/example/fixture.git", + "git_dirty": false + }, + "codestory_cache_provenance": { + "doctor_status": "pass", "storage_path": "fixture/codestory.db", + "cache_policy": "prepared-sidecar-cache-read-only", "retrieval_mode": "full", + "sidecar_generation": "fixture-generation", "manifest_embedding_backend": "llamacpp:bge-base-en-v1.5", + "semantic_backend": "llamacpp", "local_only": true, "locality_kind": "local_model_files", + "indexed": true, "freshness_status": "fresh", "semantic_ready": true, "indexing_in_timed_run": false + } + }, + { + "repo": "fixture", + "task_id": "quality-contract", + "mode": "cold_cli_packet", + "repeat": 3, + "status": "pass", + "quality": { "pass": true }, + "sufficiency": { "status": "sufficient", "sufficient_quality_mismatch": false }, + "packet_latency": { "sla_missed": false, "retrieval_shadow": { "retrieval_mode": "full" } }, + "repo_provenance": { + "manifest_overridden_by_builtin": false, + "configured": { "url": "https://github.com/example/fixture.git", "ref": "9999999999999999999999999999999999999999" }, + "manifest": { "url": "https://github.com/example/fixture.git", "ref": "9999999999999999999999999999999999999999" }, + "git_head": "9999999999999999999999999999999999999999", + "git_origin": "https://github.com/example/fixture.git", + "git_dirty": false + }, + "codestory_cache_provenance": { + "doctor_status": "pass", "storage_path": "fixture/codestory.db", + "cache_policy": "prepared-sidecar-cache-read-only", "retrieval_mode": "full", + "sidecar_generation": "fixture-generation", "manifest_embedding_backend": "llamacpp:bge-base-en-v1.5", + "semantic_backend": "llamacpp", "local_only": true, "locality_kind": "local_model_files", + "indexed": true, "freshness_status": "fresh", "semantic_ready": true, "indexing_in_timed_run": false + } + } + ] + }, + "summary": [ + { "task": "one", "mode": "cold_cli_packet", "median_e2e_wall_ms": 800 }, + { "task": "two", "mode": "cold_cli_packet", "median_e2e_wall_ms": 900 } + ] +} diff --git a/benchmarks/release-evidence/fixtures/candidate-stats.json b/benchmarks/release-evidence/fixtures/candidate-stats.json new file mode 100644 index 00000000..128ade69 --- /dev/null +++ b/benchmarks/release-evidence/fixtures/candidate-stats.json @@ -0,0 +1,40 @@ +{ + "schema_version": 1, + "commit": "2222222222222222222222222222222222222222", + "evidence_identity": { + "corpus_id": "ci-contract-corpus-v1", + "cache_id": "cold-full-sidecar-v1", + "machine_fingerprint": "ci-contract/windows-x64/8/test-cpu/16GiB" + }, + "retrieval_status_seconds": 0.8, + "ground_seconds": 0.8, + "repeat_full_refresh_seconds": 8, + "repeat_graph_phase_seconds": 10, + "repeat_semantic_phase_seconds": 2, + "stats_baseline": { + "repeat_full_refresh_seconds": 10 + }, + "search_seconds": 0.8, + "index_seconds": 8, + "storage_bytes": 1020, + "proof_tier": "full_sidecar", + "repeat_semantic_docs_embedded": 0, + "index": { + "error_count": 0, + "sidecar_status_after_retrieval_index": "full" + }, + "ground": { + "sidecar_status_after_retrieval_index": "full" + }, + "search": { + "sidecar_shadow_retrieval_mode": "full" + }, + "sidecar_manifest": { + "symbol_doc_count": 10, + "dense_projection_count": 4, + "projection_count": 4, + "semantic_policy_version": "graph_first_v1", + "graph_artifact_hash_present": true, + "dense_reason_count_total": 4 + } +} diff --git a/benchmarks/release-evidence/fixtures/candidate.json b/benchmarks/release-evidence/fixtures/candidate.json new file mode 100644 index 00000000..dd79a817 --- /dev/null +++ b/benchmarks/release-evidence/fixtures/candidate.json @@ -0,0 +1,62 @@ +{ + "schema_version": 2, + "baseline_id": "ci-contract-v1@1111111111111111111111111111111111111111", + "baseline_sha256": "e7488c0625620ebeaf669fab3b9d4150a29b60d08398a3c6a005b2ee4c152642", + "commit": "2222222222222222222222222222222222222222", + "git_state": "clean", + "profile": "ci-contract-v1", + "identity": { + "corpus_id": "ci-contract-corpus-v1", + "cache_id": "cold-full-sidecar-v1", + "machine_fingerprint": "ci-contract/windows-x64/8/test-cpu/16GiB" + }, + "artifacts": { + "stats": { + "path": "candidate-stats.json", + "sha256": "f65c55078260a4ee6a6e1c0c73346317aeb972c8fe10c0c9580cab7c963da30a", + "bytes": 1106 + }, + "packet": { + "path": "candidate-packet.json", + "sha256": "a541c576391fec8949a470c6de2920b8c9387fbdfcaad82cd42700dedae5956e", + "bytes": 4986 + } + }, + "metrics": { + "status_seconds": { + "value": 0.8, + "unit": "seconds", + "aggregation": "full-sidecar command wall time" + }, + "local_grounding_seconds": { + "value": 0.8, + "unit": "seconds", + "aggregation": "full-sidecar command wall time" + }, + "convergence_seconds": { + "value": 8, + "unit": "seconds", + "aggregation": "unchanged-corpus repeat full-refresh wall time" + }, + "packet_seconds": { + "value": 0.9, + "unit": "seconds", + "aggregation": "worst quality-gated packet median wall time" + }, + "search_seconds": { + "value": 0.8, + "unit": "seconds", + "aggregation": "full-sidecar command wall time" + }, + "indexing_seconds": { + "value": 8, + "unit": "seconds", + "aggregation": "cold full-refresh wall time" + }, + "storage_growth_ratio": { + "value": 1.02, + "unit": "ratio", + "aggregation": "candidate bytes divided by approved same-corpus bytes" + } + } +} diff --git a/benchmarks/release-evidence/fixtures/report.json b/benchmarks/release-evidence/fixtures/report.json new file mode 100644 index 00000000..31ff85f3 --- /dev/null +++ b/benchmarks/release-evidence/fixtures/report.json @@ -0,0 +1,102 @@ +{ + "schema_version": 2, + "status": "pass", + "decision": "accept_contract", + "commit": "2222222222222222222222222222222222222222", + "profile": "ci-contract-v1", + "baseline_id": "ci-contract-v1@1111111111111111111111111111111111111111", + "baseline_sha256": "e7488c0625620ebeaf669fab3b9d4150a29b60d08398a3c6a005b2ee4c152642", + "candidate_path": "benchmarks/release-evidence/fixtures/candidate.json", + "candidate_sha256": "0bae343abdfbf71c9957e234e4df0406d01682a12b18e26ab0d4d7e8fedef126", + "artifact_paths": [ + { + "path": "candidate-stats.json", + "sha256": "f65c55078260a4ee6a6e1c0c73346317aeb972c8fe10c0c9580cab7c963da30a", + "bytes": 1106 + }, + { + "path": "candidate-packet.json", + "sha256": "a541c576391fec8949a470c6de2920b8c9387fbdfcaad82cd42700dedae5956e", + "bytes": 4986 + } + ], + "metrics": [ + { + "status": "pass", + "metric": "status_seconds", + "decision": "accept", + "measured_value": 0.8, + "reference": 1, + "threshold": 1.25, + "unit": "seconds", + "aggregation": "full-sidecar command wall time", + "source": "baseline-raw.json" + }, + { + "status": "pass", + "metric": "local_grounding_seconds", + "decision": "accept", + "measured_value": 0.8, + "reference": 1, + "threshold": 1.25, + "unit": "seconds", + "aggregation": "full-sidecar command wall time", + "source": "baseline-raw.json" + }, + { + "status": "pass", + "metric": "convergence_seconds", + "decision": "accept", + "measured_value": 8, + "reference": 10, + "threshold": 11.5, + "unit": "seconds", + "aggregation": "unchanged-corpus repeat full-refresh wall time", + "source": "baseline-raw.json" + }, + { + "status": "pass", + "metric": "packet_seconds", + "decision": "accept", + "measured_value": 0.9, + "reference": 1, + "threshold": 1.1, + "unit": "seconds", + "aggregation": "worst quality-gated packet median wall time", + "source": "baseline-raw.json" + }, + { + "status": "pass", + "metric": "search_seconds", + "decision": "accept", + "measured_value": 0.8, + "reference": 1, + "threshold": 1.25, + "unit": "seconds", + "aggregation": "full-sidecar command wall time", + "source": "baseline-raw.json" + }, + { + "status": "pass", + "metric": "indexing_seconds", + "decision": "accept", + "measured_value": 8, + "reference": 10, + "threshold": 12.5, + "unit": "seconds", + "aggregation": "cold full-refresh wall time", + "source": "baseline-raw.json" + }, + { + "status": "pass", + "metric": "storage_growth_ratio", + "decision": "accept", + "measured_value": 1.02, + "reference": 1, + "threshold": 1.1, + "unit": "ratio", + "aggregation": "candidate bytes divided by approved same-corpus bytes", + "source": "baseline-raw.json" + } + ] +} diff --git a/benchmarks/release-evidence/repo-stats-contract.json b/benchmarks/release-evidence/repo-stats-contract.json new file mode 100644 index 00000000..cf17fc1c --- /dev/null +++ b/benchmarks/release-evidence/repo-stats-contract.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "repeat_graph_phase_seconds_max": 20, + "repeat_semantic_phase_seconds_max": 3, + "repeat_full_refresh_regression_factor": 1.25 +} diff --git a/crates/codestory-bench/Cargo.toml b/crates/codestory-bench/Cargo.toml index 7b150cff..9fe650ea 100644 --- a/crates/codestory-bench/Cargo.toml +++ b/crates/codestory-bench/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codestory-bench" -version = "0.14.3" +version = "0.15.0" edition = "2024" publish = false @@ -13,6 +13,7 @@ codestory-workspace = { workspace = true } criterion = { workspace = true } tempfile = { workspace = true } anyhow = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true } diff --git a/crates/codestory-cli/tests/agent_quality_eval.rs b/crates/codestory-bench/tests/agent_quality_eval.rs similarity index 100% rename from crates/codestory-cli/tests/agent_quality_eval.rs rename to crates/codestory-bench/tests/agent_quality_eval.rs diff --git a/crates/codestory-cli/tests/fixtures/agent_quality/material_correction_trap.json b/crates/codestory-bench/tests/fixtures/agent_quality/material_correction_trap.json similarity index 100% rename from crates/codestory-cli/tests/fixtures/agent_quality/material_correction_trap.json rename to crates/codestory-bench/tests/fixtures/agent_quality/material_correction_trap.json diff --git a/crates/codestory-cli/tests/fixtures/agent_quality/overclaim_trap.json b/crates/codestory-bench/tests/fixtures/agent_quality/overclaim_trap.json similarity index 100% rename from crates/codestory-cli/tests/fixtures/agent_quality/overclaim_trap.json rename to crates/codestory-bench/tests/fixtures/agent_quality/overclaim_trap.json diff --git a/crates/codestory-cli/tests/fixtures/agent_quality/real_batcave.json b/crates/codestory-bench/tests/fixtures/agent_quality/real_batcave.json similarity index 100% rename from crates/codestory-cli/tests/fixtures/agent_quality/real_batcave.json rename to crates/codestory-bench/tests/fixtures/agent_quality/real_batcave.json diff --git a/crates/codestory-cli/tests/fixtures/agent_quality/real_codestory.json b/crates/codestory-bench/tests/fixtures/agent_quality/real_codestory.json similarity index 100% rename from crates/codestory-cli/tests/fixtures/agent_quality/real_codestory.json rename to crates/codestory-bench/tests/fixtures/agent_quality/real_codestory.json diff --git a/crates/codestory-cli/tests/fixtures/agent_quality/real_rootandruntime.json b/crates/codestory-bench/tests/fixtures/agent_quality/real_rootandruntime.json similarity index 100% rename from crates/codestory-cli/tests/fixtures/agent_quality/real_rootandruntime.json rename to crates/codestory-bench/tests/fixtures/agent_quality/real_rootandruntime.json diff --git a/crates/codestory-cli/tests/fixtures/agent_quality/real_sourcetrail.json b/crates/codestory-bench/tests/fixtures/agent_quality/real_sourcetrail.json similarity index 100% rename from crates/codestory-cli/tests/fixtures/agent_quality/real_sourcetrail.json rename to crates/codestory-bench/tests/fixtures/agent_quality/real_sourcetrail.json diff --git a/crates/codestory-cli/tests/fixtures/agent_quality/synthetic_agent_quality.json b/crates/codestory-bench/tests/fixtures/agent_quality/synthetic_agent_quality.json similarity index 100% rename from crates/codestory-cli/tests/fixtures/agent_quality/synthetic_agent_quality.json rename to crates/codestory-bench/tests/fixtures/agent_quality/synthetic_agent_quality.json diff --git a/crates/codestory-cli/Cargo.toml b/crates/codestory-cli/Cargo.toml index 59254651..d5ecd05e 100644 --- a/crates/codestory-cli/Cargo.toml +++ b/crates/codestory-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codestory-cli" -version = "0.14.3" +version = "0.15.0" edition = "2024" description = "Local repository evidence and grounding CLI for source-backed coding workflows." license = "Apache-2.0" @@ -31,4 +31,5 @@ ratatui = { workspace = true } [dev-dependencies] codestory-retrieval = { workspace = true, features = ["test-support"] } +codestory-runtime = { workspace = true, features = ["test-support"] } tempfile = { workspace = true } diff --git a/crates/codestory-cli/src/args.rs b/crates/codestory-cli/src/args.rs index 51ca9ebb..4c35637a 100644 --- a/crates/codestory-cli/src/args.rs +++ b/crates/codestory-cli/src/args.rs @@ -8,7 +8,7 @@ use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; use codestory_contracts::api::{ - BookmarkCategoryDto, BookmarkDto, ClaimReadinessDto, EvidencePacketDto, GroundingBudgetDto, + AgentPacketDto, BookmarkCategoryDto, BookmarkDto, ClaimReadinessDto, GroundingBudgetDto, IndexDryRunDto, IndexFreshnessDto, IndexedFileRoleDto, IndexingPhaseTimings, LayoutDirection, NodeId, NodeKind, PacketBudgetModeDto, PacketTaskClassDto, ProjectSummary, ReadinessGoalDto, ReadinessStatusDto, ReadinessVerdictDto, RepoTextScanStatsDto, RetrievalScoreBreakdownDto, @@ -16,7 +16,7 @@ use codestory_contracts::api::{ SearchQueryAssessmentDto, SnippetContextDto, SummaryGenerationDto, SymbolContextDto, TrailCallerScope, TrailContextDto, TrailDirection, TrailMode, }; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use std::{collections::BTreeMap, path::PathBuf}; const INDEX_REFRESH_HELP: &str = "Index defaults to `auto`: it chooses `full` for an empty cache and `incremental` once the \ @@ -78,8 +78,6 @@ pub(crate) enum Command { Smoke(SmokeCommand), #[command(about = "Agent-facing readiness and repair helpers.")] Agent(AgentCommand), - #[command(about = "Install or check local setup assets.")] - Setup(SetupCommand), #[command(about = "Prepare or inspect local cache artifacts.")] Cache(CacheCommand), #[command(about = "Find symbols and repo text evidence.")] @@ -122,6 +120,16 @@ pub(crate) enum Command { Retrieval(RetrievalCommand), #[command(about = "Show retrieval sidecar status.")] Sidecar(SidecarCommand), + #[command(name = "internal-owned-delete", hide = true)] + InternalOwnedDelete(InternalOwnedDeleteCommand), +} + +#[derive(Args, Debug)] +pub(crate) struct InternalOwnedDeleteCommand { + #[arg(long, value_name = "DIR")] + pub(crate) root: PathBuf, + #[arg(long, value_name = "RELATIVE_PATH")] + pub(crate) relative: PathBuf, } #[derive(Args, Debug, Clone)] @@ -260,22 +268,6 @@ pub(crate) enum CompletionShell { Powershell, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, ValueEnum)] -#[serde(rename_all = "snake_case")] -pub(crate) enum CliEmbeddingQuant { - #[value(name = "q8_0")] - Q8_0, - #[value(name = "q4_k_m")] - Q4KM, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, ValueEnum)] -#[serde(rename_all = "snake_case")] -pub(crate) enum CliLlamaVariant { - Cpu, - Vulkan, -} - #[derive(Args, Debug)] pub(crate) struct IndexCommand { #[command(flatten)] @@ -752,7 +744,7 @@ pub(crate) enum RetrievalAction { Up(RetrievalSidecarStateCommand), /// Remove local sidecar state file (does not stop external processes). Down(RetrievalSidecarStateCommand), - /// Probe Zoekt, Qdrant, and SCIP availability for the project. + /// Validate the lexical shard plus Qdrant and SCIP availability for the project. Status(RetrievalStatusCommand), /// List owned sidecar namespaces and dry-run cleanup eligibility. Inventory(RetrievalInventoryCommand), @@ -830,7 +822,7 @@ pub(crate) struct RetrievalBootstrapCommand { long, value_name = "SECS", default_value_t = 90, - help = "Seconds to wait for Zoekt and Qdrant HTTP probes (0 = no wait)." + help = "Seconds to wait for Qdrant and embedding HTTP probes (0 = no wait)." )] pub(crate) wait_secs: u64, #[arg( @@ -914,55 +906,6 @@ pub(crate) struct RetrievalIndexCommand { pub(crate) output_file: Option, } -#[derive(Args, Debug)] -pub(crate) struct SetupCommand { - #[command(subcommand)] - pub(crate) action: SetupAction, -} - -#[derive(Subcommand, Debug)] -pub(crate) enum SetupAction { - Embeddings(SetupEmbeddingsCommand), -} - -#[derive(Args, Debug)] -pub(crate) struct SetupEmbeddingsCommand { - #[command(flatten)] - pub(crate) project: ProjectArgs, - #[arg( - long, - value_enum, - default_value_t = CliEmbeddingQuant::Q8_0, - help = "Legacy compatibility selector; setup embeddings now installs diagnostic ONNX assets only." - )] - pub(crate) quant: CliEmbeddingQuant, - #[arg( - long, - value_enum, - default_value_t = CliLlamaVariant::Vulkan, - help = "Legacy compatibility selector; product embeddings use the llama.cpp retrieval sidecar." - )] - pub(crate) variant: CliLlamaVariant, - #[arg( - long, - help = "Show the diagnostic ONNX asset plan without downloading anything." - )] - pub(crate) dry_run: bool, - #[arg( - long, - help = "Compatibility flag; diagnostic ONNX setup never starts a server." - )] - pub(crate) no_start: bool, - #[arg(long, value_name = "FORMAT", value_parser = parse_read_output_format, default_value = "markdown")] - pub(crate) format: OutputFormat, - #[arg( - long, - value_name = "PATH", - help = "Write command output to this file instead of stdout. The parent directory must already exist." - )] - pub(crate) output_file: Option, -} - #[derive(Args, Debug)] pub(crate) struct SearchCommand { #[command(flatten)] @@ -1053,6 +996,14 @@ pub(crate) struct DrillCommand { long_help = DRILL_REFRESH_HELP )] pub(crate) refresh: RefreshMode, + #[arg(long, value_enum)] + pub(crate) profile: Option, + #[arg( + long, + value_name = "ID", + help = "Agent profile run id to reuse for drill packet retrieval. Passing --run-id implies the agent profile." + )] + pub(crate) run_id: Option, #[arg(long, value_name = "FORMAT", value_parser = parse_read_output_format, default_value = "markdown")] pub(crate) format: OutputFormat, #[arg( @@ -1075,12 +1026,6 @@ pub(crate) struct DrillSuiteCommand { help = "JSON manifest describing the drill cases to run. Relative case project paths resolve from the manifest directory." )] pub(crate) case_file: PathBuf, - #[arg( - long, - value_name = "FILE", - help = "Optional source-truth ledger JSON to merge into the suite report after verification." - )] - pub(crate) ledger: Option, #[arg( long, value_name = "DIR", @@ -1974,56 +1919,6 @@ pub(crate) struct DrillAnchorOutput { pub(crate) commands: Vec, } -#[derive(Debug, Clone, Serialize)] -pub(crate) struct DrillVerificationChecklistItemOutput { - pub(crate) item: String, - pub(crate) allowed_classifications: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct DrillAnswerQualityContractOutput { - pub(crate) code_story_only_draft_required: bool, - pub(crate) source_truth_verification_required: bool, - pub(crate) pass_condition: String, - pub(crate) score_inputs: Vec, - pub(crate) correction_buckets: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct DrillClaimLedgerEntryOutput { - pub(crate) id: String, - pub(crate) claim: String, - pub(crate) expected_evidence: Vec, - pub(crate) source_truth_files: Vec, - pub(crate) pre_verification_confidence: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) classification: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) changed_after_source_read: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) correction_note: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct DrillClaimLedgerScoringOutput { - pub(crate) status: String, - pub(crate) pending_claim_count: u32, - pub(crate) correct: u32, - pub(crate) partial: u32, - pub(crate) misleading: u32, - pub(crate) unsupported: u32, - pub(crate) material_revision_count: u32, - pub(crate) score_formula: String, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct DrillClaimLedgerOutput { - pub(crate) template_version: u32, - pub(crate) instructions: Vec, - pub(crate) claims: Vec, - pub(crate) scoring: DrillClaimLedgerScoringOutput, -} - #[derive(Debug, Clone, Serialize)] pub(crate) struct DrillSummaryStatsOutput { pub(crate) files: u32, @@ -2201,11 +2096,7 @@ pub(crate) struct DrillOutput { pub(crate) execution_boundaries: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) verification_targets: Vec, - pub(crate) evidence_packet: EvidencePacketDto, - pub(crate) answer_quality_contract: DrillAnswerQualityContractOutput, - pub(crate) claim_ledger_template: DrillClaimLedgerOutput, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) verification_checklist: Vec, + pub(crate) evidence_packet: AgentPacketDto, pub(crate) next_commands: Vec, } @@ -2219,7 +2110,6 @@ pub(crate) struct DrillSuiteRepoOutput { pub(crate) artifact_extension: String, pub(crate) summary: DrillSummaryOutput, pub(crate) expectations: DrillSuiteExpectationOutput, - pub(crate) answer_quality: DrillSuiteAnswerQualityOutput, } #[derive(Debug, Clone, Serialize)] @@ -2234,42 +2124,6 @@ pub(crate) struct DrillSuiteExpectationOutput { pub(crate) allow_partial_bridges: Option, } -#[derive(Debug, Clone, Serialize)] -pub(crate) struct DrillSuiteLayerFindingOutput { - pub(crate) layer: String, - pub(crate) status: String, - pub(crate) detail: String, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct DrillSuiteAnswerQualityOutput { - pub(crate) ledger_status: String, - pub(crate) final_answer_status: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) draft_written: Option, - pub(crate) claim_count: usize, - pub(crate) claim_correct_count: usize, - pub(crate) claim_partial_count: usize, - pub(crate) claim_misleading_count: usize, - pub(crate) claim_unsupported_count: usize, - pub(crate) claim_unclassified_count: usize, - pub(crate) material_revision_count: usize, - pub(crate) expected_file_count: usize, - pub(crate) expected_file_found_count: usize, - pub(crate) expected_file_missing_count: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) expected_file_recall: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) missing_expected_files: Vec, - pub(crate) forbidden_claim_count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) forbidden_claim_hits: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) layer_findings: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) warnings: Vec, -} - #[derive(Debug, Clone, Serialize)] pub(crate) struct DrillSuiteRetrievalBlockerOutput { pub(crate) status: String, @@ -2288,10 +2142,6 @@ pub(crate) struct DrillSuiteOutput { pub(crate) degraded_count: usize, pub(crate) blocked_count: usize, pub(crate) ready_count: usize, - pub(crate) answer_ready_count: usize, - pub(crate) answer_degraded_count: usize, - pub(crate) answer_failed_count: usize, - pub(crate) answer_pending_count: usize, pub(crate) repos: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) retrieval_blockers: Vec, @@ -2758,17 +2608,18 @@ mod tests { assert!(help.contains("--label