diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index 1af53e1da..55e048d24 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -203,25 +203,24 @@ runs: safe_abi="${descriptor//[^A-Za-z0-9_.-]/-}" printf 'value=%s\n' "${safe_abi:0:80}" >> "$GITHUB_OUTPUT" - - name: Install sccache + - name: Configure Google-backed sccache if: ${{ inputs.use-sccache == 'true' }} - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + uses: ./.github/actions/setup-sccache with: version: v0.17.0 - - - name: Configure sccache - if: ${{ inputs.use-sccache == 'true' }} - shell: bash - run: | - echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" - echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + backend: auto + rust-toolchain: ${{ inputs.toolchain }} + allow-gha-fallback: "false" - name: Cache Cargo uses: Swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1 with: workspaces: ${{ steps.rust-paths.outputs.cache-workspaces }} - shared-key: ${{ runner.os }}-${{ runner.arch }}-${{ steps.host-abi.outputs.value }}-target-path-v2-${{ inputs.cache-group }} + shared-key: ${{ runner.os }}-${{ runner.arch }}-${{ steps.host-abi.outputs.value }}-cargo-inputs-v3-${{ inputs.cache-group }} add-job-id-key: "false" + # Compiler outputs belong in the Google-backed sccache bucket. Keep the + # GitHub cache limited to Cargo inputs/tooling rather than duplicating target/. + cache-targets: "false" # The action always hashes Cargo/Rust variables. The home paths above are # normalized, and these prefixes add the remaining build-affecting inputs. env-vars: "CARGO_BUILD_TARGET CARGO_ENCODED_RUSTFLAGS CARGO_INCREMENTAL CARGO_PROFILE RUSTC_WRAPPER RUSTFLAGS CC CFLAGS CXX CMAKE" diff --git a/.github/actions/setup-sccache/action.yml b/.github/actions/setup-sccache/action.yml new file mode 100644 index 000000000..796f731bd --- /dev/null +++ b/.github/actions/setup-sccache/action.yml @@ -0,0 +1,64 @@ +name: Setup sccache +description: > + Install sccache and select a backend. GCS on ARC when GCE metadata + identity is present and an objects.list probe of evalops-prod-ci-sccache + succeeds. Else local disk when the runner service exports + SCCACHE_LOCAL_DIR to an absolute writable path (WSL: + /var/cache/evalops-sccache, SCCACHE_CACHE_SIZE default 40G). GitHub + Actions cache is an opt-out fallback for callers that still require it. + Callers compile with RUSTC_WRAPPER=sccache. + +inputs: + version: + description: sccache release tag consumed by mozilla-actions/sccache-action. + required: false + default: v0.15.0 + backend: + description: > + sccache storage backend. `auto` (default) probes the GCE metadata + server and uses `gcs` when the runner identity answers and an + objects.list probe of the sccache prefix returns HTTP 200; else + `local` when SCCACHE_LOCAL_DIR is an absolute writable directory; + else `gha`. The GCS probe is GET .../b//o?maxResults=1&prefix=... + (storage.objects.list), not buckets.get, because the runner GSA + is roles/storage.objectUser. A 404 or 403 warns and falls back to + local disk if configured, else `gha`. `gcs`, `local`, and `gha` + force a backend, but `gcs` still falls back if the prefix is + unusable and `local` still falls back if the dir is missing. + required: false + default: auto + gcs-bucket: + description: GCS bucket for the sccache compiler cache on ARC runners. + required: false + default: evalops-prod-ci-sccache + rust-toolchain: + description: > + Rust toolchain used to namespace compiler objects. Callers that install + a toolchain explicitly should pass the same value; repository + rust-toolchain.toml or mise.toml remains the fallback. + required: false + default: "" + allow-gha-fallback: + description: > + Permit auto/local selection to fall back to GitHub Actions cache when + neither GCS nor persistent local disk is available. When false, use an + ephemeral local cache instead and reject an explicit gha backend. + required: false + default: "true" + +runs: + using: composite + steps: + - name: Install sccache + uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 + with: + version: ${{ inputs.version }} + disable_annotations: true + - name: Configure sccache backend + shell: bash + env: + SCCACHE_BACKEND: ${{ inputs.backend }} + SCCACHE_GCS_BUCKET_NAME: ${{ inputs.gcs-bucket }} + SCCACHE_RUST_TOOLCHAIN: ${{ inputs.rust-toolchain }} + SCCACHE_ALLOW_GHA_FALLBACK: ${{ inputs.allow-gha-fallback }} + run: bash "${GITHUB_ACTION_PATH}/configure.sh" diff --git a/.github/actions/setup-sccache/configure.sh b/.github/actions/setup-sccache/configure.sh new file mode 100755 index 000000000..70466e977 --- /dev/null +++ b/.github/actions/setup-sccache/configure.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +# Select the sccache backend and export job-wide compiler-cache env. +# Precedence: GCS on ARC when GCE metadata identity is present AND an +# objects.list probe against the bucket prefix succeeds; else local disk. +# GCP runners never fall through to GitHub's Azure-hosted cache data plane. +# Off-GCP runners use SCCACHE_LOCAL_DIR when it is an absolute writable +# directory (WSL: /var/cache/evalops-sccache, 40G by default). GHA is only +# selected when the caller explicitly permits that fallback. +# Hard-fail only if GCS or local disk was selected and sccache then reports +# a different Cache location. +set -euo pipefail + +# Single bucket name; override via the action input / SCCACHE_GCS_BUCKET_NAME. +: "${GITHUB_ENV:?GITHUB_ENV is required}" +: "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" +SCCACHE_GCS_BUCKET_NAME="${SCCACHE_GCS_BUCKET_NAME:-evalops-prod-ci-sccache}" +SCCACHE_BACKEND="${SCCACHE_BACKEND:-auto}" +SCCACHE_ALLOW_GHA_FALLBACK="${SCCACHE_ALLOW_GHA_FALLBACK:-true}" +SCCACHE_CURL="${SCCACHE_CURL:-curl}" +SCCACHE_BIN="${SCCACHE_BIN:-sccache}" +SCCACHE_METADATA_EMAIL_URL="${SCCACHE_METADATA_EMAIL_URL:-http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/email}" +SCCACHE_METADATA_TOKEN_URL="${SCCACHE_METADATA_TOKEN_URL:-http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token}" + +case "${SCCACHE_ALLOW_GHA_FALLBACK}" in + true | false) ;; + *) + echo "::error::setup-sccache allow-gha-fallback must be true or false (got ${SCCACHE_ALLOW_GHA_FALLBACK})" >&2 + exit 1 + ;; +esac + +urlencode() { + # RFC 3986 encode so prefix slashes become %2F in the objects.list query. + local s="$1" + local out="" c hex + local i + for ((i = 0; i < ${#s}; i++)); do + c="${s:i:1}" + case "${c}" in + [a-zA-Z0-9._~-]) out+="${c}" ;; + *) + printf -v hex '%02X' "'${c}" + out+="%${hex}" + ;; + esac + done + printf '%s' "${out}" +} + +parse_access_token() { + local json="$1" + local rest + rest="${json#*\"access_token\"}" + if [[ "${rest}" == "${json}" ]]; then + return 1 + fi + rest="${rest#*:}" + rest="${rest#*\"}" + rest="${rest%%\"*}" + if [[ -z "${rest}" ]]; then + return 1 + fi + printf '%s\n' "${rest}" +} + +probe_metadata() { + local url="$1" + "${SCCACHE_CURL}" -sf --connect-timeout 2 --max-time 2 \ + -H "Metadata-Flavor: Google" \ + "${url}" || true +} + +gce_identity="$(probe_metadata "${SCCACHE_METADATA_EMAIL_URL}")" +gce_identity="${gce_identity//$'\n'/}" + +# Persistent self-hosted hosts (the WSL box) export SCCACHE_LOCAL_DIR in the +# runner service environment. Ephemeral ARC pods do not; they keep GCS/GHA. +# Default size cap 40G; override with SCCACHE_CACHE_SIZE on the host. +SCCACHE_LOCAL_DIR="${SCCACHE_LOCAL_DIR:-}" +SCCACHE_CACHE_SIZE="${SCCACHE_CACHE_SIZE:-40G}" + +local_disk_ready() { + local dir="${SCCACHE_LOCAL_DIR}" + if [[ -z "${dir}" ]]; then + return 1 + fi + if [[ "${dir}" != /* ]]; then + echo "::warning::SCCACHE_LOCAL_DIR must be an absolute path (got ${dir}); ignoring" + return 1 + fi + if [[ ! -d "${dir}" ]]; then + if ! mkdir -p "${dir}"; then + echo "::warning::SCCACHE_LOCAL_DIR ${dir} could not be created; ignoring" + return 1 + fi + fi + if [[ ! -w "${dir}" ]]; then + echo "::warning::SCCACHE_LOCAL_DIR ${dir} is not writable; ignoring" + return 1 + fi + return 0 +} + +select_local_or_gha() { + if local_disk_ready; then + SCCACHE_BACKEND="local" + elif [[ "${SCCACHE_ALLOW_GHA_FALLBACK}" == "true" ]]; then + SCCACHE_BACKEND="gha" + else + SCCACHE_LOCAL_DIR="${RUNNER_TEMP:?RUNNER_TEMP is required}/sccache-no-gha-fallback" + if ! local_disk_ready; then + echo "::error::sccache ephemeral local fallback ${SCCACHE_LOCAL_DIR} is unavailable; refusing to use GitHub Actions cache" >&2 + exit 1 + fi + SCCACHE_BACKEND="local" + fi +} + +case "${SCCACHE_BACKEND}" in + auto) + if [[ -n "${gce_identity}" ]]; then + SCCACHE_BACKEND="gcs" + else + select_local_or_gha + fi + ;; + gcs | gha | local) ;; + *) + echo "::error::setup-sccache backend must be auto, gcs, local, or gha (got ${SCCACHE_BACKEND})" >&2 + exit 1 + ;; +esac + +if [[ "${SCCACHE_BACKEND}" == "gha" && "${SCCACHE_ALLOW_GHA_FALLBACK}" != "true" ]]; then + echo "::error::sccache GitHub Actions cache backend is disabled for this caller" >&2 + exit 1 +fi + +echo "sccache backend=${SCCACHE_BACKEND} gce_identity=${gce_identity:-none} local_dir=${SCCACHE_LOCAL_DIR:-none}" + +cache_source_root="${GITHUB_WORKSPACE}" +if [[ -n "${GITHUB_ACTION_PATH:-}" ]]; then + action_source_root="$(cd "${GITHUB_ACTION_PATH}/../../.." && pwd)" + if [[ -f "${action_source_root}/rust-toolchain.toml" || -f "${action_source_root}/mise.toml" ]]; then + cache_source_root="${action_source_root}" + fi +fi +rust_toolchain="${SCCACHE_RUST_TOOLCHAIN:-}" +if [[ "${rust_toolchain}" == *$'\n'* || "${rust_toolchain}" == *$'\r'* ]]; then + echo "::error::explicit sccache Rust toolchain must be a single line" >&2 + exit 1 +fi +if [[ -f "${cache_source_root}/rust-toolchain.toml" ]]; then + if [[ -z "${rust_toolchain}" ]]; then + rust_toolchain="$( + awk -F'"' '/^channel = / { print $2; exit }' "${cache_source_root}/rust-toolchain.toml" + )" + fi +fi +if [[ -z "${rust_toolchain}" && -f "${cache_source_root}/mise.toml" ]]; then + rust_toolchain="$( + awk -F'"' '/^rust = / { print $2; exit }' "${cache_source_root}/mise.toml" + )" +fi +if [[ -z "${rust_toolchain}" ]]; then + echo "::error::unable to resolve rust toolchain from rust-toolchain.toml or mise.toml" >&2 + exit 1 +fi + +runner_os="${RUNNER_OS:?RUNNER_OS is required}" +gcs_key_prefix="sccache/${runner_os}/${rust_toolchain}/" +encoded_prefix="$(urlencode "${gcs_key_prefix}")" +# objects.list, not buckets.get: the runner GSA is roles/storage.objectUser, +# which grants storage.objects.* and not storage.buckets.get. +: "${SCCACHE_GCS_BUCKET_PROBE_URL:=https://storage.googleapis.com/storage/v1/b/${SCCACHE_GCS_BUCKET_NAME}/o?maxResults=1&prefix=${encoded_prefix}&fields=kind}" + +if [[ "${SCCACHE_BACKEND}" == "gcs" ]]; then + bucket_reason="" + if [[ -z "${gce_identity}" ]]; then + bucket_reason="GCE metadata identity is unavailable" + else + token_json="$(probe_metadata "${SCCACHE_METADATA_TOKEN_URL}")" + access_token="" + if access_token="$(parse_access_token "${token_json}")"; then + : + else + access_token="" + fi + if [[ -z "${access_token}" ]]; then + bucket_reason="metadata-server token is unavailable" + else + echo "sccache GCS probe ${SCCACHE_GCS_BUCKET_PROBE_URL}" + probe_body="$(mktemp)" + http_code="" + set +e + http_code="$("${SCCACHE_CURL}" -sS --connect-timeout 2 --max-time 5 \ + -o "${probe_body}" -w '%{http_code}' \ + -H "Authorization: Bearer ${access_token}" \ + -H "Accept: application/json" \ + "${SCCACHE_GCS_BUCKET_PROBE_URL}")" + curl_status=$? + set -e + if [[ "${curl_status}" -ne 0 || -z "${http_code}" ]]; then + http_code="000" + fi + rm -f "${probe_body}" + case "${http_code}" in + 200) ;; + 404) bucket_reason="HTTP 404 (bucket missing)" ;; + 403) bucket_reason="HTTP 403 (permission denied)" ;; + 000) bucket_reason="HTTP 000 (probe transport failed)" ;; + *) bucket_reason="HTTP ${http_code}" ;; + esac + fi + fi + if [[ -n "${bucket_reason}" ]]; then + if [[ -n "${gce_identity}" && -z "${SCCACHE_LOCAL_DIR}" ]]; then + SCCACHE_LOCAL_DIR="${RUNNER_TEMP}/sccache-gcp-fallback" + fi + select_local_or_gha + if [[ "${SCCACHE_BACKEND}" == "local" ]]; then + echo "::warning::sccache GCS bucket ${SCCACHE_GCS_BUCKET_NAME} probe failed: ${bucket_reason}; falling back to local disk instead of GitHub/Azure" + else + echo "::warning::sccache GCS bucket ${SCCACHE_GCS_BUCKET_NAME} probe failed: ${bucket_reason}; falling back to the GitHub Actions cache backend" + fi + fi +fi + +if [[ "${SCCACHE_BACKEND}" == "local" ]]; then + if ! local_disk_ready; then + if [[ -n "${gce_identity}" ]]; then + SCCACHE_LOCAL_DIR="${RUNNER_TEMP}/sccache-gcp-fallback" + if ! local_disk_ready; then + echo "::error::sccache GCP local fallback ${SCCACHE_LOCAL_DIR} is unavailable; refusing to use GitHub/Azure" >&2 + exit 1 + fi + echo "::warning::sccache local disk cache is not configured; using ephemeral local disk instead of GitHub/Azure" + else + select_local_or_gha + if [[ "${SCCACHE_BACKEND}" == "local" ]]; then + echo "::warning::sccache persistent local disk cache is not configured; using ephemeral local disk instead of GitHub Actions cache" + else + echo "::warning::sccache local disk cache is not configured (set SCCACHE_LOCAL_DIR to an absolute writable path on a persistent host); falling back to the GitHub Actions cache backend" + fi + fi + fi +fi + +echo "sccache selected backend=${SCCACHE_BACKEND}" + +# Self-hosted machines run several Actions runners against one network +# namespace. Give each job its own Unix socket so concurrent sccache servers +# cannot contend for the default 127.0.0.1:4226 endpoint. +server_key="$( + printf '%s\n' \ + "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" \ + "${GITHUB_RUN_ATTEMPT:?GITHUB_RUN_ATTEMPT is required}" \ + "${GITHUB_JOB:?GITHUB_JOB is required}" \ + "${RUNNER_NAME:?RUNNER_NAME is required}" \ + | cksum | awk '{print $1}' +)" +SCCACHE_SERVER_UDS="${RUNNER_TEMP:?RUNNER_TEMP is required}/sccache-${server_key}.sock" +rm -f "${SCCACHE_SERVER_UDS}" + +{ + echo "SCCACHE_IDLE_TIMEOUT=0" + echo "RUSTC_WRAPPER=sccache" + echo "SCCACHE_BASEDIRS=${GITHUB_WORKSPACE}" + echo "SCCACHE_SERVER_UDS=${SCCACHE_SERVER_UDS}" +} >>"${GITHUB_ENV}" +export SCCACHE_IDLE_TIMEOUT=0 +export RUSTC_WRAPPER=sccache +export SCCACHE_BASEDIRS="${GITHUB_WORKSPACE}" +export SCCACHE_SERVER_UDS + +if [[ "${SCCACHE_BACKEND}" == "gcs" ]]; then + { + echo "SCCACHE_GCS_BUCKET=${SCCACHE_GCS_BUCKET_NAME}" + echo "SCCACHE_GCS_RW_MODE=READ_WRITE" + echo "SCCACHE_GCS_KEY_PREFIX=${gcs_key_prefix}" + } >>"${GITHUB_ENV}" + export SCCACHE_GCS_BUCKET="${SCCACHE_GCS_BUCKET_NAME}" + export SCCACHE_GCS_RW_MODE=READ_WRITE + export SCCACHE_GCS_KEY_PREFIX="${gcs_key_prefix}" + # Do not set SCCACHE_GHA_ENABLED: that would select the GitHub Actions + # cache backend. mozilla-actions/sccache-action only installs the binary. + # Do not set SCCACHE_GCS_CREDENTIALS_URL: that is TaskCluster token fetch + # in sccache 0.15. OpenDAL Gcs loads the GCE metadata server token when + # no credential_path / token is set (default service account). +elif [[ "${SCCACHE_BACKEND}" == "local" ]]; then + { + echo "SCCACHE_DIR=${SCCACHE_LOCAL_DIR}" + echo "SCCACHE_CACHE_SIZE=${SCCACHE_CACHE_SIZE}" + } >>"${GITHUB_ENV}" + export SCCACHE_DIR="${SCCACHE_LOCAL_DIR}" + export SCCACHE_CACHE_SIZE="${SCCACHE_CACHE_SIZE}" + # Do not set SCCACHE_GHA_ENABLED or GCS vars: local disk is SCCACHE_DIR. +else + { + echo "SCCACHE_GHA_ENABLED=true" + # GITHUB_ENV persists values across steps. Empty assignments prevent + # inherited runner or earlier-step GCS settings from overriding the + # selected GHA backend after this script exits. + echo "SCCACHE_GCS_BUCKET=" + echo "SCCACHE_GCS_RW_MODE=" + echo "SCCACHE_GCS_KEY_PREFIX=" + } >>"${GITHUB_ENV}" + export SCCACHE_GHA_ENABLED=true + # A prior step or runner image may have left GCS vars set. sccache 0.15 + # prefers GCS whenever SCCACHE_GCS_BUCKET is present, even if GHA is + # enabled, and --show-stats then prints Cache location gcs. + unset SCCACHE_GCS_BUCKET SCCACHE_GCS_RW_MODE SCCACHE_GCS_KEY_PREFIX +fi + +"${SCCACHE_BIN}" --start-server +stats="$("${SCCACHE_BIN}" --show-stats)" +printf '%s\n' "${stats}" + +if [[ "${SCCACHE_BACKEND}" == "gcs" ]]; then + # sccache 0.15 RemoteStorage::location prints OpenDAL scheme/name/root: + # "gcs, name: , prefix: " + if ! grep -qF "gcs, name: ${SCCACHE_GCS_BUCKET_NAME}" <<<"${stats}"; then + echo "::error::sccache Cache location is not GCS bucket ${SCCACHE_GCS_BUCKET_NAME} on ARC after a successful bucket probe" >&2 + exit 1 + fi +elif [[ "${SCCACHE_BACKEND}" == "local" ]]; then + # sccache 0.15 disk cache prints the directory in Cache location. + if ! grep -qF "${SCCACHE_DIR}" <<<"${stats}"; then + echo "::error::sccache Cache location is not local disk ${SCCACHE_DIR} after SCCACHE_LOCAL_DIR was accepted" >&2 + exit 1 + fi +fi diff --git a/.repository-projection.json b/.repository-projection.json index ca4ec5dc3..57017d6b9 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "6878323651fdb771b8cbf76b08597b930938ce36", + "sourceSha": "3fcbaa13c7fe99ae16f2328afbc5c7131ef7a9b4", "destinationRepository": "dx-corp/code", - "priorProjectedBase": "4c484fec884560fda66a55a5a61722edf1a2d71e", - "definitionDigest": "07c1b987e17ae0398b4af8817192627b95c45168c305069446594fdaa3414682", - "toolDigest": "16fcaea2f0e7e35de09bcd8e515d915f65cdf1eb13033eaf2845f60eaa9e0a4f", - "contentDigest": "325d36d4b27c054e6edd55e71460299cdf56f8dc3ed887ae92ac326b531e60bc", + "priorProjectedBase": "56ee3b6a474fbc2f81ac3325a8e424b82d582d36", + "definitionDigest": "064b6b9745d392dde277afd7dfdf028cff32b6178a8b9c99c518343219bab429", + "toolDigest": "9f3ee17d06ac34337873740d0109c3274bff8315d312227eb55383c5d169116d", + "contentDigest": "b9b7fdb3ace95244a69c465cb0c16c40e5890a2dc1ef59c9a6d92e504e6bbfb0", "publicationEligible": true } diff --git a/Cargo.lock b/Cargo.lock index c109de597..9f36e11e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,6 +69,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -90,7 +99,7 @@ dependencies = [ "glob", "hex", "octocrab", - "rand 0.8.7", + "rand 0.10.2", "regex", "reqwest 0.12.28", "serde", @@ -98,7 +107,7 @@ dependencies = [ "serde_yaml", "sha2 0.10.9", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-test", @@ -220,7 +229,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "x11rb", ] @@ -251,7 +260,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -369,7 +378,7 @@ dependencies = [ "serde_json", "serde_nanos", "serde_repr", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tokio-rustls", @@ -436,9 +445,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -468,9 +477,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b180a3c8b55960db3426d8964b8745e652466a1a49fe1a2eda828046d30b5e4" +checksum = "a767267da9e2c2e189b2f9df8b5657e850ecf5352644734ba130d4a57095cf1b" dependencies = [ "aws-credential-types", "aws-runtime", @@ -511,9 +520,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -522,9 +531,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -561,9 +570,9 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.139.0" +version = "1.142.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dffe4bd3da45048fa0d30b50febaedf093e93a25d832cc21f2845a6e0c72616" +checksum = "4844547a354fb4e41895f4c9c423e7a7de13e3b2adc3f3493a2f2d8aa397c294" dependencies = [ "arc-swap", "aws-credential-types", @@ -590,9 +599,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.105.0" +version = "1.108.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ffd0fbe7873cb548a7aa60f9573c268fff94155397fd4f14dc9f1ecaaab8516" +checksum = "c15301b04372832947916607983b114b3374b9db0be058a00fb7513800de1f05" dependencies = [ "arc-swap", "aws-credential-types", @@ -616,9 +625,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.107.0" +version = "1.110.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175763eb222a46377df7aa257a3bca980ab3e96703fefc8f4d0b8da6ad2e254c" +checksum = "72cc2c205cb27108183cf1856333f7d584c2ba0f505421b4209ca5828f9ea899" dependencies = [ "arc-swap", "aws-credential-types", @@ -642,9 +651,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.110.0" +version = "1.113.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b14781dfbff48984017d57167b6ea0b6471c6920ec52b44a2677c7feb3c13" +checksum = "68182ecb449f7537db0f4d5d25917789cf41e32074a9fe47b6a0b847fe1d2032" dependencies = [ "arc-swap", "aws-credential-types", @@ -703,9 +712,9 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.61.1" +version = "0.61.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944" +checksum = "80c2051c2f1016fb8e6548dd07b8bc2ac9c3fe583721444b92f515e856d31609" dependencies = [ "aws-smithy-types", "bytes", @@ -736,9 +745,9 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.2.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e" +checksum = "7bd25384a4e437aa8d8f339afad4b69e786b936a7cb10db668a7aaf66717b1a8" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -793,9 +802,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.12.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1" +checksum = "b82e438d30e02a825d363bd639a9efaed68a8089d86101054b0081e7e0d3e606" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -819,9 +828,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.14.0" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb" +checksum = "6d881a7b7ad179fd6611680c9de89f716fb00ab40299a9a7b8c6913e8f7511a8" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", @@ -859,9 +868,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.6.1" +version = "1.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" +checksum = "0b791f3ac597193fe1d08b82366986eb1f5bc31f2ac6c194c0855276116c76cd" dependencies = [ "base64-simd", "bytes", @@ -897,9 +906,9 @@ dependencies = [ [[package]] name = "aws-types" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94" +checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -1245,9 +1254,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.5" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -1255,9 +1264,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.5" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -1267,9 +1276,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", @@ -1313,7 +1322,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1637,25 +1646,24 @@ dependencies = [ [[package]] name = "criterion" -version = "0.5.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ + "alloca", "anes", "cast", "ciborium", "clap", "criterion-plot", - "is-terminal", - "itertools 0.10.5", + "itertools 0.13.0", "num-traits", - "once_cell", "oorandom", + "page_size", "plotters", "rayon", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "walkdir", @@ -1663,12 +1671,12 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools 0.10.5", + "itertools 0.13.0", ] [[package]] @@ -1767,9 +1775,9 @@ dependencies = [ [[package]] name = "ctor" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" dependencies = [ "link-section", "linktime-proc-macro", @@ -1794,7 +1802,22 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", "rustc_version", "subtle", ] @@ -1969,9 +1992,9 @@ dependencies = [ [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys", ] @@ -2056,7 +2079,16 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "signature", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "signature 3.0.0", ] [[package]] @@ -2065,10 +2097,22 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", "sha2 0.10.9", - "signature", + "signature 2.2.0", + "subtle", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", + "sha2 0.11.0", "subtle", ] @@ -2126,12 +2170,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - [[package]] name = "equivalent" version = "1.0.2" @@ -2278,7 +2316,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2296,6 +2334,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -2333,12 +2377,12 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -2396,9 +2440,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2411,9 +2455,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2421,15 +2465,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2438,9 +2482,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -2457,32 +2501,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -3128,20 +3172,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" - -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is_ci" @@ -3157,9 +3190,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.10.5" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -3211,7 +3244,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -3277,7 +3310,7 @@ checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ "base64 0.22.1", "js-sys", - "pem", + "pem 3.0.6", "ring", "serde", "serde_json", @@ -3294,10 +3327,10 @@ dependencies = [ "base64 0.22.1", "getrandom 0.2.17", "js-sys", - "pem", + "pem 3.0.6", "serde", "serde_json", - "signature", + "signature 2.2.0", "simple_asn1", "zeroize", ] @@ -3321,14 +3354,14 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "keyring" -version = "4.1.6" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72585bb6cc9bc370d1d545b7e23fcce71dfd4461c5e15275e3cf51bdfd9a980a" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" dependencies = [ "apple-native-keyring-store", "keyring-core", @@ -3379,7 +3412,7 @@ checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1" dependencies = [ "enumflags2", "libc", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3437,15 +3470,15 @@ dependencies = [ [[package]] name = "link-section" -version = "0.19.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee1a0d6e252afe82e7bc2db42fba60e02ddf3b1accaf8cb21d96e34ba61f3d4" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" [[package]] name = "linktime-proc-macro" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "348d0075b1fc163b26d72a7f75fc5141daf2fd1bdf128d873cbaf6785d495bdf" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" [[package]] name = "linux-raw-sys" @@ -3502,7 +3535,7 @@ dependencies = [ "rangemap", "sha2 0.10.9", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.20", "ttf-parser", "weezl", ] @@ -3524,18 +3557,18 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lua-src" -version = "547.0.0" +version = "551.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edaf29e3517b49b8b746701e5648ccb5785cde1c119062cbabbc5d5cd115e42" +checksum = "d400ffef0e3d4d29287092bdc5a276d0bf468b2c69709d5bab0d469312d9f947" dependencies = [ "cc", ] [[package]] name = "luajit-src" -version = "210.5.12+a4f56a4" +version = "210.7.3+1ee778a" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a8e7962a5368d5f264d045a5a255e90f9aa3fc1941ae15a8d2940d42cac671" +checksum = "869665372263eb337b14f480cfb864b89f12eade4eb42cb415f71517b4a67572" dependencies = [ "cc", "which", @@ -3601,7 +3634,7 @@ dependencies = [ "aws-sdk-bedrockruntime", "aws-smithy-types", "aws-types", - "base64 0.22.1", + "base64 0.23.1", "eventsource-stream", "futures", "maestro-runtime-contracts", @@ -3671,12 +3704,12 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.23.1", "chrono", "clap", "dirs", "dunce", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "eventsource-stream", "fd-lock", "futures", @@ -3707,7 +3740,7 @@ dependencies = [ "prost-build", "prost-types", "protoc-bin-vendored", - "rand 0.9.5", + "rand 0.10.2", "rcgen", "regex", "reqwest 0.12.28", @@ -3721,7 +3754,7 @@ dependencies = [ "shlex 1.3.0", "similar", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tokio-rustls", @@ -3758,7 +3791,7 @@ name = "maestro-runtime" version = "0.1.0" dependencies = [ "anyhow", - "base64 0.22.1", + "base64 0.23.1", "chrono", "dirs", "futures", @@ -3768,13 +3801,13 @@ dependencies = [ "maestro-context", "maestro-runtime-contracts", "maestro-sandbox", - "rand 0.9.5", + "rand 0.10.2", "regex", "serde", "serde_json", "sha2 0.10.9", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -3806,7 +3839,7 @@ name = "maestro-runtime-gateway" version = "0.1.0" dependencies = [ "anyhow", - "base64 0.22.1", + "base64 0.23.1", "chrono", "dunce", "fs2", @@ -3821,10 +3854,10 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "sha1 0.10.7", + "sha1 0.11.0", "sha2 0.11.0", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -3839,13 +3872,13 @@ dependencies = [ "dunce", "landlock", "libc", - "rand 0.9.5", + "rand 0.10.2", "regex", "seccompiler", "serde", "serde_json", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "uuid", ] @@ -3898,7 +3931,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "ureq", "zstd", ] @@ -3923,14 +3956,14 @@ dependencies = [ "anyhow", "arboard", "async-trait", - "base64 0.22.1", + "base64 0.23.1", "chrono", "clap", "criterion", "crossterm", "dirs", "dunce", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "eventsource-stream", "fd-lock", "futures", @@ -3967,7 +4000,7 @@ dependencies = [ "prost", "prost-types", "pulldown-cmark", - "rand 0.9.5", + "rand 0.10.2", "ratatui", "rcgen", "regex", @@ -3980,19 +4013,19 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.10.9", - "shlex 1.3.0", + "shlex 2.0.1", "similar", "supports-color", "syntect", "tempfile", "textwrap", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tokio-rustls", "tokio-stream", "tokio-util", - "toml 0.8.23", + "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", "tree-sitter", @@ -4056,7 +4089,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", ] @@ -4125,6 +4158,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -4139,30 +4182,31 @@ dependencies = [ [[package]] name = "mlua" -version = "0.10.5" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1f5f8fbebc7db5f671671134b9321c4b9aa9adeafccfd9a8c020ae45c6a35d0" +checksum = "d96e5d00f19d8c46c71ceaced99593b90c31c57aa1fe2cb3e93a8b1698eedba9" dependencies = [ "bstr", "either", "erased-serde", + "libc", "mlua-sys", "num-traits", "parking_lot", "rustc-hash", - "rustversion", "serde", "serde-value", ] [[package]] name = "mlua-sys" -version = "0.6.8" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380c1f7e2099cafcf40e51d3a9f20a346977587aa4d012eae1f043149a728a93" +checksum = "b806d7ade031f5d6607eae3e283fb034cb795a76247dd0d1ba753c8c42debccf" dependencies = [ "cc", "cfg-if", + "libc", "lua-src", "luajit-src", "pkg-config", @@ -4228,11 +4272,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" dependencies = [ "data-encoding", - "ed25519", - "ed25519-dalek", + "ed25519 2.2.3", + "ed25519-dalek 2.2.0", "getrandom 0.2.17", "log", - "rand 0.8.7", + "rand 0.8.8", "signatory", ] @@ -4297,7 +4341,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" dependencies = [ - "rand 0.8.7", + "rand 0.8.8", ] [[package]] @@ -4663,7 +4707,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -4692,7 +4736,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest 0.13.5", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -4719,7 +4763,7 @@ dependencies = [ "percent-encoding", "portable-atomic", "rand 0.9.5", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", ] @@ -4779,6 +4823,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "palette" version = "0.7.7" @@ -4884,6 +4938,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -4989,7 +5053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.7", + "rand 0.8.8", ] [[package]] @@ -5111,7 +5175,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -5264,7 +5328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", - "itertools 0.10.5", + "itertools 0.14.0", "log", "multimap", "petgraph 0.8.3", @@ -5283,7 +5347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -5430,7 +5494,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -5453,7 +5517,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -5496,9 +5560,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -5617,7 +5681,7 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -5708,11 +5772,11 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.8" +version = "0.14.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" dependencies = [ - "pem", + "pem 4.0.0", "ring", "rustls-pki-types", "time", @@ -5748,7 +5812,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -5974,9 +6038,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -6039,9 +6103,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -6414,7 +6478,7 @@ checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" dependencies = [ "pkcs8", "rand_core 0.6.4", - "signature", + "signature 2.2.0", "zeroize", ] @@ -6428,6 +6492,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + [[package]] name = "simd-adler32" version = "0.3.10" @@ -6452,9 +6522,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "similar" -version = "3.1.2" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ee016af5d736b69fc89e19254540fa4b5f5492853fb5503920f084011c78b6" +checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" dependencies = [ "bstr", ] @@ -6467,7 +6537,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -6677,7 +6747,7 @@ dependencies = [ "regex-syntax", "serde", "serde_derive", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", ] @@ -6807,11 +6877,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -6827,9 +6897,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -6971,9 +7041,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -7028,7 +7098,7 @@ dependencies = [ "futures-sink", "http 1.5.0", "httparse", - "rand 0.8.7", + "rand 0.8.8", "ring", "rustls-pki-types", "tokio", @@ -7279,9 +7349,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.25.10" +version = "0.26.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +checksum = "17ebdd3a5a7e28a1890b876fdbd0c3c0fe0a6336cffaa104f11b9f720c9daa29" dependencies = [ "cc", "regex", @@ -7470,9 +7540,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.0" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ "base64 0.23.1", "cookie_store", @@ -7489,9 +7559,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.1" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" dependencies = [ "base64 0.23.1", "http 1.5.0", @@ -7538,9 +7608,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "atomic", "getrandom 0.4.3", @@ -7730,12 +7800,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.255.0" +version = "0.259.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b524283fb5df62eec102ed0574838961bdd7ba5ac9c50d38e2756c51c971a42" +checksum = "b1d0246511d901aacf25d2dc9111f0054947de5e093fe662099e709ff7530dc3" dependencies = [ "leb128fmt", - "wasmparser 0.255.0", + "wasmparser 0.259.0", ] [[package]] @@ -7766,9 +7836,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.255.0" +version = "0.259.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8e329ef4b5d46e73b91d3ac6924417cad55a8cbbf869c199283383427c3320b" +checksum = "0f7c12eac7bb587801590f6a67ff0bc84d0748513864b71108e4b311cc3df694" dependencies = [ "bitflags 2.13.1", "indexmap", @@ -7942,7 +8012,7 @@ dependencies = [ "pulley-interpreter", "smallvec", "target-lexicon", - "thiserror 2.0.19", + "thiserror 2.0.20", "wasmparser 0.252.0", "wasmtime-environ", "wasmtime-internal-core", @@ -8028,22 +8098,22 @@ dependencies = [ [[package]] name = "wast" -version = "255.0.0" +version = "259.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ffec530f199bd3d553ac442c13dd108353cad533cad8514bc41e1f1f0fe686" +checksum = "c69beba8d9da07af9a0971b559149a2ac4729895b55144de4fddbf5a02660648" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width", - "wasm-encoder 0.255.0", + "wasm-encoder 0.259.0", ] [[package]] name = "wat" -version = "1.255.0" +version = "1.259.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dda82c82e1486c7eed42a0465e544d80fff37abc3b39482e0d34dbaabe2fe5b1" +checksum = "c6eec44b0c80391b20fb7ad9ea440c72c5d382385bdf74444bb18321c425017e" dependencies = [ "wast", ] @@ -8175,14 +8245,11 @@ dependencies = [ [[package]] name = "which" -version = "7.0.3" +version = "8.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" dependencies = [ - "either", - "env_home", - "rustix", - "winsafe", + "libc", ] [[package]] @@ -8480,12 +8547,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -8548,7 +8609,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] diff --git a/Cargo.toml b/Cargo.toml index f129dd339..a2d01e214 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,12 +46,12 @@ aws-sdk-bedrockruntime = { version = "1.139.0", default-features = false, featur ] } aws-smithy-types = "1.6.1" aws-types = "1.5.0" -base64 = "0.22" +base64 = "0.23" chrono = "0.4" clap = { version = "4", features = ["derive"] } ctor = "1.0.11" crossterm = "0.29" -dirs = "6" +dirs = "7" dunce = "1" eventsource-stream = "0.2" fd-lock = "4" @@ -103,7 +103,7 @@ serde_yaml = "0.9" sha2 = "0.10" similar = "3" rayon = "1.10" -rand = "0.9" +rand = "0.10" thiserror = "2" zeroize = "1" tempfile = "3" @@ -120,7 +120,7 @@ wait-timeout = "0.2" windows-sys = "0.61" x509-parser = "0.18" zip = { version = "8", default-features = false, features = ["aes-crypto", "deflate64", "deflate", "lzma", "ppmd", "time", "zstd", "xz"] } -tree-sitter = "0.25" +tree-sitter = "0.26" tree-sitter-bash = "0.25.1" # Keep the canonical release profile deliberately optimized for shipped diff --git a/packages/ai-rs/src/client.rs b/packages/ai-rs/src/client.rs index 8abd978ae..239042986 100644 --- a/packages/ai-rs/src/client.rs +++ b/packages/ai-rs/src/client.rs @@ -1022,6 +1022,15 @@ async fn forward_stream_with_idle_policy_with_span( loop { attempt += 1; if attempt > 1 { + // Fast 502s must not burn the entire recovery budget in a + // single burst. Keep the existing attempt limit and owner; + // never replay content or tools already sent downstream. + let delay = std::time::Duration::from_secs(1u64 << (attempt - 2).min(3)); + tokio::select! { + biased; + () = tx.closed() => return, + () = tokio::time::sleep(delay) => {} + } observe(StreamObservation::Retry); } let Some(begin_attempt_fn) = begin_attempt.as_mut() else { @@ -2310,8 +2319,9 @@ mod stream_idle_policy_tests { )); } - #[tokio::test] + #[tokio::test(start_paused = true)] async fn transient_provider_error_is_retried_only_by_stream_owner() { + let started = tokio::time::Instant::now(); let attempts = Attempts::new(AtomicU32::new(0)); let (first_tx, first_rx) = mpsc::unbounded_channel(); first_tx @@ -2344,6 +2354,7 @@ mod stream_idle_policy_tests { .await; assert_eq!(attempts.load(Ordering::SeqCst), RETRIES); + assert_eq!(started.elapsed(), Duration::from_secs(3)); let events = drain(&mut rx); assert!(matches!( events.as_slice(), @@ -2354,8 +2365,73 @@ mod stream_idle_policy_tests { )); } - #[tokio::test] + #[tokio::test(start_paused = true)] + async fn transient_502_recovers_after_backoff_without_forwarding_failed_attempts() { + let started = tokio::time::Instant::now(); + let mut attempts = Vec::new(); + let (tx, mut rx) = mpsc::unbounded_channel(); + forward_stream_with_idle_policy( + None::, + || { + attempts.push(started.elapsed()); + let (attempt_tx, attempt_rx) = mpsc::unbounded_channel(); + let event = if started.elapsed() < Duration::from_secs(3) { + StreamEvent::ProviderError { + kind: ProviderStreamErrorKind::TransientProtocol, + message: "API error 502 Bad Gateway: error code: 502".into(), + } + } else { + StreamEvent::MessageStop { stop_reason: None } + }; + attempt_tx.send(event).unwrap(); + async move { Ok(attempt_rx) } + }, + IDLE, + RETRIES, + tx, + ) + .await; + assert_eq!( + attempts, + [ + Duration::ZERO, + Duration::from_secs(1), + Duration::from_secs(3) + ] + ); + assert!(matches!( + drain(&mut rx).as_slice(), + [StreamEvent::MessageStop { .. }] + )); + } + + #[tokio::test(start_paused = true)] + async fn consumer_cancellation_interrupts_retry_backoff_before_new_request() { + let (first_tx, first_rx) = mpsc::unbounded_channel(); + first_tx + .send(StreamEvent::ProviderError { + kind: ProviderStreamErrorKind::TransientProtocol, + message: "API error 502 Bad Gateway: error code: 502".into(), + }) + .unwrap(); + let (tx, rx) = mpsc::unbounded_channel(); + let started = tokio::time::Instant::now(); + let forwarder = tokio::spawn(forward_stream_with_idle_policy( + Some(first_rx), + || async { unreachable!("cancelled backoff must not open another request") }, + IDLE, + RETRIES, + tx, + )); + first_tx.closed().await; + drop(rx); + forwarder.await.unwrap(); + assert_eq!(started.elapsed(), Duration::ZERO); + } + + #[tokio::test(start_paused = true)] async fn retry_open_failures_exhaust_one_stream_budget_with_one_typed_terminal() { + let started = tokio::time::Instant::now(); let retry_starts = Attempts::new(AtomicU32::new(0)); let (first_tx, first_rx) = mpsc::unbounded_channel(); first_tx @@ -2384,6 +2460,7 @@ mod stream_idle_policy_tests { RETRIES, "the initial request plus retry starts must equal the bounded attempt budget" ); + assert_eq!(started.elapsed(), Duration::from_secs(3)); let events = drain(&mut rx); assert!(matches!( events.as_slice(), diff --git a/packages/ambient-agent-rs/Cargo.toml b/packages/ambient-agent-rs/Cargo.toml index 87bb107c5..f2a34d0ec 100644 --- a/packages/ambient-agent-rs/Cargo.toml +++ b/packages/ambient-agent-rs/Cargo.toml @@ -43,7 +43,7 @@ clap.workspace = true async-trait = "0.1" # Utilities -rand = "0.8" +rand = "0.10" uuid.workspace = true chrono = { workspace = true, features = ["serde"] } sha2.workspace = true diff --git a/packages/ambient-agent-rs/src/ipc.rs b/packages/ambient-agent-rs/src/ipc.rs index 808261cc0..0060c2393 100644 --- a/packages/ambient-agent-rs/src/ipc.rs +++ b/packages/ambient-agent-rs/src/ipc.rs @@ -7,7 +7,6 @@ //! Security: Uses token-based authentication stored in a file with restricted permissions. use crate::daemon::DaemonStats; -use rand::RngCore; use serde::{Deserialize, Serialize}; use std::fs::Permissions; use std::os::unix::fs::PermissionsExt; @@ -120,7 +119,7 @@ impl From for StatsResponse { /// Generate a random auth token fn generate_token() -> String { let mut bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut bytes); + rand::fill(&mut bytes); bytes.iter().map(|b| format!("{:02x}", b)).collect() } @@ -474,4 +473,14 @@ mod tests { assert!(!server.verify_token("invalid")); assert!(!server.verify_token("")); } + + #[test] + fn generated_tokens_are_random_hex() { + let first = generate_token(); + let second = generate_token(); + + assert_eq!(first.len(), 64); + assert!(first.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert_ne!(first, second); + } } diff --git a/packages/coding-acceptance-rs/BUILD.bazel b/packages/coding-acceptance-rs/BUILD.bazel index b019e4dc3..6d40e13e4 100644 --- a/packages/coding-acceptance-rs/BUILD.bazel +++ b/packages/coding-acceptance-rs/BUILD.bazel @@ -5,7 +5,7 @@ load("@rules_rust//rust:defs.bzl", "rust_library") package(default_visibility = ["//visibility:public"]) -exports_files(["src/lib.rs", "src/computer.rs"]) +exports_files(["src/lib.rs", "src/computer.rs", "src/computer_evidence.rs", "src/hosted.rs"]) cargo_toml_env_vars( name = "cargo_toml_env_vars", diff --git a/packages/coding-acceptance-rs/src/computer.rs b/packages/coding-acceptance-rs/src/computer.rs index e4c829969..a81dab162 100644 --- a/packages/coding-acceptance-rs/src/computer.rs +++ b/packages/coding-acceptance-rs/src/computer.rs @@ -230,10 +230,10 @@ impl CodingWorkspaceResponse { self.validate()?; let valid = match request { CodingWorkspaceRequest::Baseline { .. } => { - self.clean - && self.base_revision == self.revision - && self.outputs.is_empty() - && self.readiness.is_none() + // A fresh owner can observe committed progress without + // resetting it. The Computer adapter authenticates ancestry + // and the accepted root; this DTO does not prove Git ancestry. + self.clean && self.outputs.is_empty() && self.readiness.is_none() } CodingWorkspaceRequest::Inspect { expected_base_revision, @@ -301,6 +301,21 @@ mod tests { outputs: vec![], } } + #[test] + fn baseline_can_reobserve_clean_committed_progress() { + let request = CodingWorkspaceRequest::Baseline { + idempotency_key: "fresh-owner-execution".into(), + }; + let mut result = response(); + assert_ne!(result.base_revision, result.revision); + assert!(result.validate_for(&request).is_ok()); + result.clean = false; + assert!(result.validate_for(&request).is_err()); + result.clean = true; + result.owner_lease_epoch = 0; + assert!(result.validate_for(&request).is_err()); + } + #[test] fn coding_workspace_result_rejects_wrong_revision_content_and_shape() { let mut no_lease = response(); diff --git a/packages/coding-acceptance-rs/src/computer_evidence.rs b/packages/coding-acceptance-rs/src/computer_evidence.rs new file mode 100644 index 000000000..2c475a5f1 --- /dev/null +++ b/packages/coding-acceptance-rs/src/computer_evidence.rs @@ -0,0 +1,377 @@ +//! Completion checks over Computer results retained by the existing worker. + +use super::computer::{CodingWorkspaceRequest, CodingWorkspaceResponse, ComputerCodingProof}; +use super::{CodingAcceptanceContract, CodingCompletionSubmission}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +pub const COMPUTER_CODING_EVIDENCE_KEY: &str = "codingComputerEvidence"; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ComputerCodingFence { + pub organization_id: String, + pub workspace_id: String, + pub actor_id: String, + pub work_id: String, + pub runtime_run_id: String, + pub maestro_run_id: String, + pub maestro_session_id: String, + pub session_epoch: u64, + pub runtime_generation: u64, + pub task_id: String, + pub task_computer_id: String, + pub project_resource_id: String, + pub accepted_snapshot_id: String, + pub source_workspace_id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ComputerCodingExecution { + pub request: CodingWorkspaceRequest, + pub response: CodingWorkspaceResponse, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ComputerCodingEvidence { + pub fence: ComputerCodingFence, + pub executions: Vec, +} + +/// Resolve against owner records, never against execution IDs in model output. +pub fn validate_computer_completion( + evidence: &ComputerCodingEvidence, + proof: &ComputerCodingProof, + contract: &CodingAcceptanceContract, + submission: &CodingCompletionSubmission, +) -> Result<(), &'static str> { + if evidence.executions.is_empty() + || evidence.executions.len() > 32 + || submission.contract_digest != contract.digest() + || submission.implementation_session_id != evidence.fence.maestro_session_id + || submission.work_id != evidence.fence.work_id + { + return Err("coding Computer completion does not match its owner fence"); + } + let mut records = BTreeMap::new(); + for execution in &evidence.executions { + execution.response.validate_for(&execution.request)?; + let response = &execution.response; + if response.owner_lease_epoch != evidence.fence.runtime_generation + || response.run_id != evidence.fence.maestro_run_id + || response.task_computer_id != evidence.fence.task_computer_id + || response.project_resource_id != evidence.fence.project_resource_id + || response.accepted_snapshot_id != evidence.fence.accepted_snapshot_id + || response.source_workspace_id != evidence.fence.source_workspace_id + || records + .insert(response.tool_execution_id.as_str(), execution) + .is_some() + { + return Err("coding Computer execution does not match admitted source"); + } + } + let mut ids: Vec<&str> = vec![&proof.baseline_execution_id, &proof.inspection_execution_id]; + ids.extend(proof.outputs_execution_id.as_deref()); + ids.extend(proof.readiness_execution_ids.iter().map(String::as_str)); + if ids.iter().any(|id| id.is_empty()) || ids.iter().collect::>().len() != ids.len() + { + return Err("coding Computer proof repeats or omits execution identities"); + } + let baseline = records + .get(proof.baseline_execution_id.as_str()) + .ok_or("missing coding baseline execution")?; + let inspection = records + .get(proof.inspection_execution_id.as_str()) + .ok_or("missing coding inspection execution")?; + if !matches!(baseline.request, CodingWorkspaceRequest::Baseline { .. }) + || !matches!(inspection.request, CodingWorkspaceRequest::Inspect { .. }) + || !inspection.response.clean + || inspection.response.base_revision != baseline.response.base_revision + || inspection.response.revision != submission.revision + { + return Err("coding Computer revision is not the inspected admitted revision"); + } + if contract.output_paths.is_empty() { + if proof.outputs_execution_id.is_some() || !submission.outputs.is_empty() { + return Err("unrequested coding Computer output"); + } + } else { + let output_id = proof + .outputs_execution_id + .as_deref() + .ok_or("missing coding output execution")?; + let output = records + .get(output_id) + .ok_or("unknown coding output execution")?; + if !matches!(output.request, CodingWorkspaceRequest::Outputs { .. }) + || output.response.base_revision != baseline.response.base_revision + || output.response.revision != submission.revision + { + return Err("coding Computer output revision mismatch"); + } + let captured: BTreeMap<_, _> = output + .response + .outputs + .iter() + .map(|file| (&file.path, &file.content)) + .collect(); + let submitted: BTreeMap<_, _> = submission + .outputs + .iter() + .map(|file| (&file.path, &file.content)) + .collect(); + if captured != submitted + || submitted.len() != submission.outputs.len() + || captured.keys().copied().collect::>() + != contract.output_paths.iter().collect::>() + { + return Err("coding Computer output bytes do not match the owner capture"); + } + } + if proof.readiness_execution_ids.len() != submission.commands.len() + || submission.commands.is_empty() + { + return Err("coding Computer command proof is incomplete"); + } + for (execution_id, command) in proof + .readiness_execution_ids + .iter() + .zip(&submission.commands) + { + let execution = records + .get(execution_id.as_str()) + .ok_or("unknown coding readiness execution")?; + let readiness = execution + .response + .readiness + .as_ref() + .ok_or("missing coding readiness result")?; + if !matches!(execution.request, CodingWorkspaceRequest::Readiness { .. }) + || execution.response.base_revision != baseline.response.base_revision + || execution.response.revision != submission.revision + || readiness.command != command.command + || readiness.exit_code != 0 + || readiness.timed_out + || readiness.truncated + || command.exit_code != Some(0) + || !command + .evidence_refs + .contains(&format!("receipt:tool-execution:{execution_id}")) + { + return Err("coding Computer readiness is not a successful owner execution"); + } + } + Ok(()) +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodingValidatorReservation { + pub tool_execution_id: String, + pub role: super::CodingValidationRole, + pub inspection_execution_id: String, + pub readiness_execution_ids: Vec, + pub contract_digest: String, + pub revision: String, + pub parent_session_id: String, + pub runtime_run_id: String, + pub session_epoch: u64, + pub runtime_generation: u64, +} + +impl ComputerCodingEvidence { + /// Preserve an owner's first result across response loss and lease reclaim. + pub fn record(&mut self, execution: ComputerCodingExecution) -> Result { + execution.response.validate_for(&execution.request)?; + let response = &execution.response; + if response.owner_lease_epoch != self.fence.runtime_generation + || response.run_id != self.fence.maestro_run_id + || response.task_computer_id != self.fence.task_computer_id + || response.project_resource_id != self.fence.project_resource_id + || response.accepted_snapshot_id != self.fence.accepted_snapshot_id + || response.source_workspace_id != self.fence.source_workspace_id + { + return Err("coding Computer result belongs to a different task or source"); + } + if let Some(existing) = self + .executions + .iter() + .find(|old| old.response.tool_execution_id == response.tool_execution_id) + { + return if existing == &execution { + Ok(false) + } else { + Err("coding Computer execution replay conflicts") + }; + } + if self.executions.len() >= 32 { + return Err("coding Computer evidence limit reached"); + } + self.executions.push(execution); + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::super::computer::{ + CODING_WORKSPACE_SCHEMA, CodingWorkspaceBaselineKind, CodingWorkspaceOutput, + CodingWorkspaceReadiness, + }; + use super::*; + use sha2::{Digest, Sha256}; + + fn fixture() -> ( + ComputerCodingEvidence, + ComputerCodingProof, + CodingAcceptanceContract, + CodingCompletionSubmission, + ) { + let (mut contract, mut submission, _) = super::super::tests::fixture(); + contract.output_paths = vec!["answer.py".into()]; + submission.contract_digest = contract.digest(); + submission.outputs = vec![super::super::CodingOutputFile { + path: "answer.py".into(), + content: "answer\n".into(), + }]; + submission.commands[0].evidence_refs = vec!["receipt:tool-execution:ready".into()]; + let base = "b".repeat(40); + let revision = submission.revision.clone(); + let response = |id: &str| CodingWorkspaceResponse { + owner_lease_epoch: 1, + schema: CODING_WORKSPACE_SCHEMA.into(), + project_resource_id: "project".into(), + accepted_snapshot_id: "snapshot".into(), + source_workspace_id: "source".into(), + task_computer_id: "computer".into(), + run_id: "maestro-run".into(), + tool_execution_id: id.into(), + base_revision: base.clone(), + revision: revision.clone(), + baseline_kind: CodingWorkspaceBaselineKind::AcceptedSnapshot, + clean: true, + readiness: None, + outputs: vec![], + }; + let mut baseline = response("baseline"); + baseline.revision = base.clone(); + let mut readiness = response("ready"); + readiness.readiness = Some(CodingWorkspaceReadiness { + command: "cargo test".into(), + revision_before: revision.clone(), + revision_after: revision.clone(), + exit_code: 0, + stdout: "passed".into(), + stderr: String::new(), + truncated: false, + timed_out: false, + }); + let mut outputs = response("outputs"); + outputs.outputs = vec![CodingWorkspaceOutput { + path: "answer.py".into(), + content: "answer\n".into(), + sha256: format!("{:x}", Sha256::digest(b"answer\n")), + git_blob_id: "c".repeat(40), + }]; + let evidence = ComputerCodingEvidence { + fence: ComputerCodingFence { + organization_id: "org-1".into(), + workspace_id: "workspace-1".into(), + actor_id: "actor".into(), + work_id: submission.work_id.clone(), + runtime_run_id: "runtime".into(), + maestro_run_id: "maestro-run".into(), + maestro_session_id: submission.implementation_session_id.clone(), + session_epoch: 1, + runtime_generation: 1, + task_id: "task".into(), + task_computer_id: "computer".into(), + project_resource_id: "project".into(), + accepted_snapshot_id: "snapshot".into(), + source_workspace_id: "source".into(), + }, + executions: vec![ + ComputerCodingExecution { + request: CodingWorkspaceRequest::Baseline { + idempotency_key: "baseline-key".into(), + }, + response: baseline, + }, + ComputerCodingExecution { + request: CodingWorkspaceRequest::Inspect { + expected_base_revision: base.clone(), + }, + response: response("inspection"), + }, + ComputerCodingExecution { + request: CodingWorkspaceRequest::Readiness { + idempotency_key: "ready-key".into(), + expected_revision: revision.clone(), + command: "cargo test".into(), + timeout_ms: 5000, + }, + response: readiness, + }, + ComputerCodingExecution { + request: CodingWorkspaceRequest::Outputs { + expected_base_revision: base, + expected_revision: revision, + paths: vec!["answer.py".into()], + }, + response: outputs, + }, + ], + }; + let proof = ComputerCodingProof { + baseline_execution_id: "baseline".into(), + inspection_execution_id: "inspection".into(), + outputs_execution_id: Some("outputs".into()), + readiness_execution_ids: vec!["ready".into()], + }; + (evidence, proof, contract, submission) + } + + #[test] + fn computer_completion_requires_exact_owner_bytes_commands_and_scope() { + let (evidence, proof, contract, submission) = fixture(); + assert!(validate_computer_completion(&evidence, &proof, &contract, &submission).is_ok()); + let mut wrong = submission.clone(); + wrong.outputs[0].content = "forged\n".into(); + assert!(validate_computer_completion(&evidence, &proof, &contract, &wrong).is_err()); + let mut wrong = submission.clone(); + wrong.commands[0].evidence_refs = vec!["unrelated-receipt".into()]; + assert!(validate_computer_completion(&evidence, &proof, &contract, &wrong).is_err()); + let mut wrong = submission.clone(); + wrong.implementation_session_id = "other-session".into(); + assert!(validate_computer_completion(&evidence, &proof, &contract, &wrong).is_err()); + let mut wrong = evidence.clone(); + wrong.executions[3].response.accepted_snapshot_id = "other-snapshot".into(); + assert!(validate_computer_completion(&wrong, &proof, &contract, &submission).is_err()); + let mut wrong = evidence.clone(); + wrong.executions[2] + .response + .readiness + .as_mut() + .unwrap() + .timed_out = true; + assert!(validate_computer_completion(&wrong, &proof, &contract, &submission).is_err()); + let mut wrong = evidence.clone(); + wrong.executions[0].response.owner_lease_epoch += 1; + assert!(validate_computer_completion(&wrong, &proof, &contract, &submission).is_err()); + let mut wrong = proof.clone(); + wrong.readiness_execution_ids.clear(); + assert!(validate_computer_completion(&evidence, &wrong, &contract, &submission).is_err()); + } + + #[test] + fn computer_owner_evidence_replay_cannot_replace_first_result() { + let (mut evidence, _, _, _) = fixture(); + let last = evidence.executions.last().unwrap().clone(); + assert!(!evidence.record(last.clone()).unwrap()); + let mut wrong = last; + wrong.response.outputs[0].git_blob_id = "d".repeat(40); + assert!(evidence.record(wrong).is_err()); + } +} diff --git a/packages/coding-acceptance-rs/src/hosted.rs b/packages/coding-acceptance-rs/src/hosted.rs new file mode 100644 index 000000000..f66a17683 --- /dev/null +++ b/packages/coding-acceptance-rs/src/hosted.rs @@ -0,0 +1,565 @@ +//! Pure hosted coding_task projection. The worker supplies owner-checkpointed +//! facts and persists returned transitions; this module performs no effects. + +use super::{computer::*, computer_evidence::*, *}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +pub enum HostedCodingAction { + Begin { + mission_id: String, + work_id: String, + contract: CodingAcceptanceContract, + }, + Readiness { + #[serde(default)] + requirement_id: Option, + #[serde(default)] + execution_id: Option, + }, + Validate { + role: CodingValidationRole, + }, + Status {}, + Handoff { + item: CodingHandoffItem, + }, + Complete {}, +} + +pub struct HostedCodingContext<'a> { + pub contract: &'a CodingAcceptanceContract, + pub work_id: &'a str, + pub session_id: &'a str, + pub evidence: &'a ComputerCodingEvidence, + pub readiness_bindings: &'a BTreeMap, + pub children: &'a [(CodingAcceptanceChildRecord, CodingValidationReport)], + pub handoff_items: &'a [CodingHandoffItem], +} + +pub enum HostedCodingProjection { + Response(Value), + ReadinessBinding { + requirement_id: String, + execution_id: String, + }, + Validate(CodingValidationRole), + Handoff(CodingHandoffItem), + Complete(Box), +} + +/// Hosted metadata exposes the same coding_task actions as native Code. Its +/// readiness binding refers to an already settled Computer result, not caller +/// status, exit codes, output bytes, or fabricated validator records. +pub fn hosted_coding_task_schema() -> Value { + json!({"type":"object","oneOf":[ + {"type":"object","properties":{"action":{"const":"begin"},"mission_id":{"type":"string","minLength":1},"work_id":{"type":"string","minLength":1},"contract":{"type":"object"}},"required":["action","mission_id","work_id","contract"],"additionalProperties":false}, + {"type":"object","properties":{"action":{"const":"readiness"},"requirement_id":{"type":"string","enum":["build","test","start","authentication","observation"]},"execution_id":{"type":"string","minLength":1}},"required":["action"],"additionalProperties":false}, + {"type":"object","properties":{"action":{"const":"validate"},"role":{"enum":["review","behavior"]}},"required":["action","role"],"additionalProperties":false}, + {"type":"object","properties":{"action":{"enum":["status","complete"]}},"required":["action"],"additionalProperties":false}, + {"type":"object","properties":{"action":{"const":"handoff"},"item":{"type":"object"}},"required":["action","item"],"additionalProperties":false} + ]}) +} + +fn inspection<'a>( + context: &'a HostedCodingContext<'_>, +) -> Result<&'a ComputerCodingExecution, &'static str> { + context + .evidence + .executions + .iter() + .rev() + .find(|execution| matches!(execution.request, CodingWorkspaceRequest::Inspect { .. })) + .filter(|execution| execution.response.clean) + .ok_or("coding_task requires a clean owner Computer inspection") +} + +fn readiness_execution<'a>( + context: &'a HostedCodingContext<'_>, + id: &str, + revision: &str, +) -> Result<&'a ComputerCodingExecution, &'static str> { + let record = context + .evidence + .executions + .iter() + .find(|e| e.response.tool_execution_id == id) + .ok_or("unknown owner readiness execution")?; + record.response.validate_for(&record.request)?; + let readiness = record + .response + .readiness + .as_ref() + .ok_or("execution is not readiness")?; + if !matches!(record.request, CodingWorkspaceRequest::Readiness { .. }) + || record.response.revision != revision + || !record.response.clean + || readiness.exit_code != 0 + || readiness.timed_out + || readiness.truncated + { + return Err("readiness did not succeed at the inspected revision"); + } + Ok(record) +} + +pub fn project_hosted_coding_action( + action: HostedCodingAction, + context: &HostedCodingContext<'_>, +) -> Result { + context.contract.validate()?; + if context.work_id != context.evidence.fence.work_id + || context.session_id != context.evidence.fence.maestro_session_id + { + return Err("coding_task owner scope does not match this session and work"); + } + match action { + HostedCodingAction::Begin { + mission_id, + work_id, + contract, + } => { + if mission_id.trim().is_empty() + || work_id != context.work_id + || contract != *context.contract + { + return Err("coding_task begin does not match the admitted work contract"); + } + let baseline = context + .evidence + .executions + .iter() + .find(|e| matches!(e.request, CodingWorkspaceRequest::Baseline { .. })) + .ok_or("coding_task begin requires an owner Computer baseline")?; + baseline.response.validate_for(&baseline.request)?; + Ok(HostedCodingProjection::Response( + json!({"missionId":mission_id,"workId":work_id,"contract":contract,"baseRevision":baseline.response.base_revision,"repositoryId":baseline.response.project_resource_id}), + )) + } + HostedCodingAction::Readiness { + requirement_id: Some(requirement_id), + execution_id: Some(execution_id), + } => { + if !context + .contract + .readiness_requirements + .contains(&requirement_id) + { + return Err("readiness category is not admitted"); + } + readiness_execution( + context, + &execution_id, + &inspection(context)?.response.revision, + )?; + Ok(HostedCodingProjection::ReadinessBinding { + requirement_id, + execution_id, + }) + } + HostedCodingAction::Readiness { + requirement_id: None, + execution_id: None, + } + | HostedCodingAction::Status {} => { + let inspected = inspection(context)?; + let missing: Vec<_> = context + .contract + .readiness_requirements + .iter() + .filter(|id| { + context.readiness_bindings.get(*id).is_none_or(|execution| { + readiness_execution(context, execution, &inspected.response.revision) + .is_err() + }) + }) + .collect(); + Ok(HostedCodingProjection::Response( + json!({"contract":context.contract,"revision":inspected.response.revision,"readinessBindings":context.readiness_bindings,"missingReadiness":missing,"handoffItems":context.handoff_items}), + )) + } + HostedCodingAction::Readiness { .. } => { + Err("readiness requires both requirement_id and execution_id") + } + HostedCodingAction::Validate { role } => { + let revision = &inspection(context)?.response.revision; + for requirement in &context.contract.readiness_requirements { + readiness_execution( + context, + context + .readiness_bindings + .get(requirement) + .ok_or("required readiness has no owner execution")?, + revision, + )?; + } + Ok(HostedCodingProjection::Validate(role)) + } + HostedCodingAction::Handoff { item } => { + if item.id.is_empty() + || item.evidence_refs.is_empty() + || (matches!( + item.disposition, + CodingHandoffDisposition::Deferred | CodingHandoffDisposition::Dismissed + ) && !context.contract.authorized_dispositions.contains(&item.id)) + { + return Err("handoff item is missing evidence or an authorized disposition"); + } + Ok(HostedCodingProjection::Handoff(item)) + } + HostedCodingAction::Complete {} => complete(context) + .map(Box::new) + .map(HostedCodingProjection::Complete), + } +} + +fn complete(context: &HostedCodingContext<'_>) -> Result { + let inspected = inspection(context)?; + let revision = &inspected.response.revision; + let baseline = context + .evidence + .executions + .iter() + .find(|e| matches!(e.request, CodingWorkspaceRequest::Baseline { .. })) + .ok_or("missing owner baseline")?; + let mut commands = Vec::new(); + let mut readiness = Vec::new(); + let mut readiness_execution_ids = Vec::new(); + for requirement in &context.contract.readiness_requirements { + let id = context + .readiness_bindings + .get(requirement) + .ok_or("required readiness has no owner execution")?; + let record = readiness_execution(context, id, revision)?; + let evidence_refs = vec![format!("receipt:tool-execution:{id}")]; + readiness.push(CodingAssertionResult { + assertion_id: requirement.clone(), + status: CodingVerificationStatus::Passed, + evidence_refs: evidence_refs.clone(), + }); + if !readiness_execution_ids.contains(id) { + readiness_execution_ids.push(id.clone()); + commands.push(CodingCommandResult { + command: record + .response + .readiness + .as_ref() + .ok_or("missing readiness")? + .command + .clone(), + exit_code: Some(0), + evidence_refs, + }); + } + } + let child = |role| { + context + .children + .iter() + .rev() + .find(|(record, report)| { + record.role == role + && record.revision == *revision + && record.parent_session_id == context.session_id + && record.report_digest == report.digest() + }) + .map(|(_, report)| report.clone()) + .ok_or("missing actual independent validator completion") + }; + let output = if context.contract.output_paths.is_empty() { + None + } else { + Some( + context + .evidence + .executions + .iter() + .rev() + .find(|e| { + matches!(e.request, CodingWorkspaceRequest::Outputs { .. }) + && e.response.revision == *revision + }) + .ok_or("missing immutable output capture")?, + ) + }; + let proof = ComputerCodingProof { + baseline_execution_id: baseline.response.tool_execution_id.clone(), + inspection_execution_id: inspected.response.tool_execution_id.clone(), + outputs_execution_id: output.map(|e| e.response.tool_execution_id.clone()), + readiness_execution_ids, + }; + let submission = CodingCompletionSubmission { + task_id: context.contract.task_id.clone(), + work_id: context.work_id.to_owned(), + repository_id: context.contract.repository_id.clone(), + contract_digest: context.contract.digest(), + generation: context.contract.generation, + revision: revision.clone(), + implementation_session_id: context.session_id.to_owned(), + commands, + readiness, + review: Some(child(CodingValidationRole::Review)?), + behavior: Some(child(CodingValidationRole::Behavior)?), + handoff_items: context.handoff_items.to_vec(), + outputs: output + .into_iter() + .flat_map(|e| { + e.response.outputs.iter().map(|file| CodingOutputFile { + path: file.path.clone(), + content: file.content.clone(), + }) + }) + .collect(), + computer_proof: Some(Box::new(proof.clone())), + }; + validate_computer_completion(context.evidence, &proof, context.contract, &submission)?; + let records: Vec<_> = context + .children + .iter() + .map(|(record, _)| record.clone()) + .collect(); + let decision = evaluate_coding_acceptance( + context.contract, + Some(&submission), + &CodingAcceptanceScope { + organization_id: &context.evidence.fence.organization_id, + workspace_id: &context.evidence.fence.workspace_id, + work_id: context.work_id, + implementation_session_id: context.session_id, + }, + &records, + ); + if !decision.accepted { + return Err("coding completion was not accepted by the existing contract evaluator"); + } + Ok(submission) +} + +#[cfg(test)] +mod tests { + use super::*; + fn contract() -> CodingAcceptanceContract { + serde_json::from_value(json!({"taskId":"task","repositoryId":"project","generation":1, + "requiredAssertionIds":["works"],"requireReview":true,"requireBehavior":true,"readinessRequirements":["test"]})).unwrap() + } + fn evidence() -> ComputerCodingEvidence { + let base = "a".repeat(40); + let revision = "b".repeat(40); + let response = CodingWorkspaceResponse { + owner_lease_epoch: 1, + schema: CODING_WORKSPACE_SCHEMA.into(), + project_resource_id: "project".into(), + accepted_snapshot_id: "snapshot".into(), + source_workspace_id: "source".into(), + task_computer_id: "computer".into(), + run_id: "run".into(), + tool_execution_id: "baseline".into(), + base_revision: base.clone(), + revision: base.clone(), + baseline_kind: CodingWorkspaceBaselineKind::AcceptedSnapshot, + clean: true, + readiness: None, + outputs: vec![], + }; + let mut inspected = response.clone(); + inspected.tool_execution_id = "inspect".into(); + inspected.revision = revision.clone(); + let mut ready = inspected.clone(); + ready.tool_execution_id = "ready".into(); + ready.readiness = Some(CodingWorkspaceReadiness { + command: "test".into(), + revision_before: revision.clone(), + revision_after: revision.clone(), + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + truncated: false, + timed_out: false, + }); + ComputerCodingEvidence { + fence: ComputerCodingFence { + organization_id: "org".into(), + workspace_id: "workspace".into(), + actor_id: "actor".into(), + work_id: "work".into(), + runtime_run_id: "runtime".into(), + maestro_run_id: "run".into(), + maestro_session_id: "parent".into(), + session_epoch: 1, + runtime_generation: 1, + task_id: "task".into(), + task_computer_id: "computer".into(), + project_resource_id: "project".into(), + accepted_snapshot_id: "snapshot".into(), + source_workspace_id: "source".into(), + }, + executions: vec![ + ComputerCodingExecution { + request: CodingWorkspaceRequest::Baseline { + idempotency_key: "baseline".into(), + }, + response, + }, + ComputerCodingExecution { + request: CodingWorkspaceRequest::Inspect { + expected_base_revision: base, + }, + response: inspected, + }, + ComputerCodingExecution { + request: CodingWorkspaceRequest::Readiness { + idempotency_key: "ready".into(), + expected_revision: revision, + command: "test".into(), + timeout_ms: 1000, + }, + response: ready, + }, + ], + } + } + fn children() -> Vec<(CodingAcceptanceChildRecord, CodingValidationReport)> { + [ + (CodingValidationRole::Review, "review"), + (CodingValidationRole::Behavior, "behavior"), + ] + .into_iter() + .map(|(role, id)| { + let report = CodingValidationReport { + child_id: id.into(), + session_id: format!("session-{id}"), + revision: "b".repeat(40), + status: CodingVerificationStatus::Passed, + assertions: vec![CodingAssertionResult { + assertion_id: "works".into(), + status: CodingVerificationStatus::Passed, + evidence_refs: vec!["observed".into()], + }], + evidence_refs: vec!["observed".into()], + }; + let record = CodingAcceptanceChildRecord { + organization_id: "org".into(), + workspace_id: "workspace".into(), + work_id: "work".into(), + parent_session_id: "parent".into(), + child_id: id.into(), + session_id: report.session_id.clone(), + role, + revision: report.revision.clone(), + completed_successfully: true, + report_digest: report.digest(), + }; + (record, report) + }) + .collect() + } + #[test] + fn hosted_completion_projects_only_owner_results_and_child_records() { + let contract = contract(); + let evidence = evidence(); + let bindings = BTreeMap::from([("test".into(), "ready".into())]); + let children = children(); + let context = HostedCodingContext { + contract: &contract, + work_id: "work", + session_id: "parent", + evidence: &evidence, + readiness_bindings: &bindings, + children: &children, + handoff_items: &[], + }; + let HostedCodingProjection::Complete(result) = + project_hosted_coding_action(HostedCodingAction::Complete {}, &context).unwrap() + else { + panic!("completion expected") + }; + assert_eq!(result.readiness[0].status, CodingVerificationStatus::Passed); + assert_eq!( + result.commands[0].evidence_refs, + vec!["receipt:tool-execution:ready"] + ); + assert_eq!( + result.computer_proof.unwrap().inspection_execution_id, + "inspect" + ); + } + #[test] + fn no_caller_status_exit_or_validator_record_is_accepted() { + for value in [ + json!({"action":"complete","review":{"childId":"forged"}}), + json!({"action":"readiness","requirement_id":"test","execution_id":"ready","status":"passed"}), + json!({"action":"readiness","exit_code":0}), + ] { + assert!(serde_json::from_value::(value).is_err()); + } + } + #[test] + fn missing_children_failed_readiness_and_wrong_session_fail_closed() { + let contract = contract(); + let bindings = BTreeMap::from([("test".into(), "ready".into())]); + let children = children(); + for scenario in 0..3 { + let mut evidence = evidence(); + if scenario == 1 { + evidence.executions[2] + .response + .readiness + .as_mut() + .unwrap() + .exit_code = 1; + } + let context = HostedCodingContext { + contract: &contract, + work_id: "work", + session_id: if scenario == 2 { "other" } else { "parent" }, + evidence: &evidence, + readiness_bindings: &bindings, + children: if scenario == 0 { &[] } else { &children }, + handoff_items: &[], + }; + assert!( + project_hosted_coding_action(HostedCodingAction::Complete {}, &context).is_err() + ); + } + } + #[test] + fn readiness_binding_must_name_an_admitted_category_and_real_execution() { + let contract = contract(); + let evidence = evidence(); + let bindings = BTreeMap::new(); + let context = HostedCodingContext { + contract: &contract, + work_id: "work", + session_id: "parent", + evidence: &evidence, + readiness_bindings: &bindings, + children: &[], + handoff_items: &[], + }; + assert!( + project_hosted_coding_action( + HostedCodingAction::Readiness { + requirement_id: Some("test".into()), + execution_id: Some("ready".into()) + }, + &context + ) + .is_ok() + ); + for (requirement, id) in [("test", "inspect"), ("test", "forged"), ("build", "ready")] { + assert!( + project_hosted_coding_action( + HostedCodingAction::Readiness { + requirement_id: Some(requirement.into()), + execution_id: Some(id.into()) + }, + &context + ) + .is_err() + ); + } + } +} diff --git a/packages/coding-acceptance-rs/src/lib.rs b/packages/coding-acceptance-rs/src/lib.rs index 274b3776a..a2bbfeb2e 100644 --- a/packages/coding-acceptance-rs/src/lib.rs +++ b/packages/coding-acceptance-rs/src/lib.rs @@ -5,6 +5,8 @@ #![forbid(unsafe_code)] pub mod computer; +pub mod computer_evidence; +pub mod hosted; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -160,6 +162,8 @@ pub struct CodingCompletionSubmission { pub handoff_items: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub outputs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub computer_proof: Option>, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -462,7 +466,7 @@ mod tests { } } - fn fixture() -> ( + pub(super) fn fixture() -> ( CodingAcceptanceContract, CodingCompletionSubmission, Vec, @@ -513,6 +517,7 @@ mod tests { }) .collect(); let submission = CodingCompletionSubmission { + computer_proof: None, task_id: contract.task_id.clone(), work_id: "work-1".into(), repository_id: contract.repository_id.clone(), diff --git a/packages/local-host-rs/Cargo.toml b/packages/local-host-rs/Cargo.toml index 90f790d8e..b16d37740 100644 --- a/packages/local-host-rs/Cargo.toml +++ b/packages/local-host-rs/Cargo.toml @@ -57,7 +57,7 @@ wait-timeout.workspace = true # Serialization for IPC ring.workspace = true -ed25519-dalek = { version = "2", default-features = false } +ed25519-dalek = { version = "3", default-features = false } serde.workspace = true serde_json.workspace = true prost = "0.14" @@ -117,7 +117,7 @@ serde_yaml.workspace = true # Lua scripting for hooks (optional) # Uses vendored Lua to avoid system dependency # "send" feature makes Lua thread-safe (uses Arc instead of Rc) -mlua = { version = "0.10", features = ["lua54", "serialize", "vendored", "send"], optional = true } +mlua = { version = "0.12", features = ["lua54", "serialize", "vendored", "send"], optional = true } # WASM runtime for hook plugins (optional) wasmtime = { version = "47", optional = true } diff --git a/packages/local-host-rs/src/agent/native_codex_tests.rs b/packages/local-host-rs/src/agent/native_codex_tests.rs index dc65b70a8..c4bec5fc1 100644 --- a/packages/local-host-rs/src/agent/native_codex_tests.rs +++ b/packages/local-host-rs/src/agent/native_codex_tests.rs @@ -1902,6 +1902,17 @@ else if(x.method==='turn/start'){ .env("MAESTRO_CODEX_FAILURE_MARKER", marker) .env("MAESTRO_HOME", &maestro_home) .env("CODEX_HOME", &codex_home) + // This child owns a standalone fixture configuration. Managed + // policy tests in the parent process publish temporary paths, and + // inheriting one after its fixture is removed can block startup + // before the Codex retry behavior under test is reached. + .env_remove("MAESTRO_MANAGED_POLICY_PATH") + .env_remove("MAESTRO_MANAGED_POLICY_STATE_PATH") + .env_remove("MAESTRO_MANAGED_POLICY_PUBLIC_KEY") + .env_remove("MAESTRO_MANAGED_POLICY_KEY_ID") + .env_remove("MAESTRO_MANAGED_POLICY_AUDIT_PATH") + .env_remove("MAESTRO_ENTERPRISE_POLICY_PATH") + .env_remove("MAESTRO_POLICY_PATH") .env("MAESTRO_OAUTH_STORAGE_MODE", "file") .env("MAESTRO_DISABLE_KEYCHAIN", "1") .env("MAESTRO_CODEX_APP_SERVER_COMMAND", "node") diff --git a/packages/local-host-rs/src/agent/native_host.rs b/packages/local-host-rs/src/agent/native_host.rs index ce7e6680a..77cfe5c16 100644 --- a/packages/local-host-rs/src/agent/native_host.rs +++ b/packages/local-host-rs/src/agent/native_host.rs @@ -692,6 +692,49 @@ impl NativeExecutionHost for LocalNativeExecutionHost { self.with_hooks(move |hooks| hooks.set_log_file(path)) } + fn hook_set_enabled(&self, enabled: bool) -> NativeHostFuture<'_, ()> { + self.with_hooks(move |hooks| { + if enabled { + hooks.enable(); + } else { + hooks.disable(); + } + }) + } + + fn hook_reload(&self) -> NativeHostFuture<'_, Result<(), String>> { + self.with_hooks(|hooks| { + hooks + .reload() + .map(|_| ()) + .map_err(|error| error.to_string()) + }) + } + + fn hook_runtime_snapshot( + &self, + ) -> NativeHostFuture<'_, maestro_runtime::agent::native_host::NativeHookRuntimeSnapshot> { + self.with_hooks(|hooks| { + let stats = hooks.stats(); + let metrics = hooks.metrics(); + maestro_runtime::agent::native_host::NativeHookRuntimeSnapshot { + native_hooks: stats.native_hooks, + lua_scripts: stats.lua_scripts, + wasm_plugins: stats.wasm_plugins, + enabled: stats.enabled, + pre_tool_use_count: metrics.pre_tool_use_count, + post_tool_use_count: metrics.post_tool_use_count, + overflow_count: metrics.overflow_count, + blocks: metrics.blocks, + total_duration_ms: metrics + .total_duration + .as_millis() + .try_into() + .unwrap_or(u64::MAX), + } + }) + } + fn render_hook_context(&self, event: NativeHookEvent, context: &str) -> Result { render_hook_context(Self::hook_event(event), context) .map_err(|error| render_hook_context_error(&error)) diff --git a/packages/local-host-rs/src/agent/native_lifecycle_tests.rs b/packages/local-host-rs/src/agent/native_lifecycle_tests.rs index 552fa9358..dc89d10ba 100644 --- a/packages/local-host-rs/src/agent/native_lifecycle_tests.rs +++ b/packages/local-host-rs/src/agent/native_lifecycle_tests.rs @@ -296,6 +296,44 @@ async fn read_pid(path: &Path) -> i32 { .expect("bash process should publish its pid") } +#[tokio::test] +async fn hook_management_reads_and_changes_the_live_host() { + let workspace = tempfile::tempdir().expect("workspace"); + let client = UnifiedClient::Scripted(ScriptedClient::new( + "scripted-replay/maestro-replay-v1", + Vec::new(), + )); + let hooks = + IntegratedHookSystem::load_from_config(workspace.path().to_str().expect("utf-8 workspace")); + let (agent, _events) = + scripted_agent_with_tui_host(scripted_config(workspace.path()), client, hooks) + .expect("scripted agent"); + + assert!( + agent + .inspect_hooks() + .await + .expect("initial snapshot") + .enabled + ); + agent.set_hooks_enabled(false).expect("disable hooks"); + assert!( + !agent + .inspect_hooks() + .await + .expect("disabled snapshot") + .enabled + ); + agent.set_hooks_enabled(true).expect("enable hooks"); + let enabled = agent.inspect_hooks().await.expect("enabled snapshot"); + assert!(enabled.enabled); + assert!( + enabled.native_hooks >= 1, + "the built-in safety hook is live" + ); + agent.shutdown().await; +} + #[tokio::test] async fn buffered_prompt_does_not_run_hooks_or_emit_prompt_events_after_shutdown() { let workspace = tempfile::tempdir().expect("workspace"); diff --git a/packages/local-host-rs/src/doctor.rs b/packages/local-host-rs/src/doctor.rs index 6e7332d4f..aad897ee6 100644 --- a/packages/local-host-rs/src/doctor.rs +++ b/packages/local-host-rs/src/doctor.rs @@ -1,6 +1,6 @@ //! Typed native `maestro doctor` report. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Component, Path, PathBuf}; use std::time::{Duration, Instant}; @@ -38,6 +38,10 @@ pub struct DoctorCheck { #[serde(skip_serializing_if = "Option::is_none")] pub detail: Option, pub live: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remediation: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -62,20 +66,46 @@ pub struct DoctorReport { struct DoctorOptions { json: bool, live: bool, + verbose: bool, + timeout_ms: u64, model: Option, + categories: HashSet, } fn parse_options(args: &[String]) -> Result { let mut options = DoctorOptions { json: false, live: false, + verbose: false, + timeout_ms: 10_000, model: None, + categories: HashSet::new(), }; let mut index = 0; while index < args.len() { match args[index].as_str() { "--json" => options.json = true, "--live" => options.live = true, + "--verbose" => options.verbose = true, + "--timeout" => { + index += 1; + options.timeout_ms = args + .get(index) + .context("--timeout requires milliseconds")? + .parse::() + .context("--timeout must be a positive integer")?; + if options.timeout_ms == 0 { + bail!("--timeout must be a positive integer"); + } + } + value if value.starts_with("--timeout=") => { + options.timeout_ms = value[10..] + .parse::() + .context("--timeout must be a positive integer")?; + if options.timeout_ms == 0 { + bail!("--timeout must be a positive integer"); + } + } "--model" => { index += 1; options.model = Some(args.get(index).context("--model requires a value")?.clone()); @@ -83,6 +113,13 @@ fn parse_options(args: &[String]) -> Result { value if value.starts_with("--model=") => { options.model = Some(value[8..].to_owned()); } + "--connectivity" | "--network" => { + options.categories.insert("network".to_owned()); + } + "--config" | "--certs" | "--proxy" | "--auth" | "--daemon" | "--runner" | "--mcp" + | "--hooks" | "--plugins" => { + options.categories.insert(args[index][2..].to_owned()); + } "--help" | "-h" | "help" => bail!("help"), other => bail!("unknown doctor option: {other}"), } @@ -104,6 +141,8 @@ fn check( summary: summary.into(), detail, live, + duration_ms: None, + remediation: None, } } @@ -674,6 +713,425 @@ async fn managed_setup_check( } } +fn measured(mut item: DoctorCheck, started: Instant, verbose: bool) -> DoctorCheck { + if verbose { + item.duration_ms = Some(started.elapsed().as_millis().try_into().unwrap_or(u64::MAX)); + } + item +} + +fn mcp_config_check(cwd: &Path, verbose: bool) -> DoctorCheck { + let started = Instant::now(); + let config = crate::mcp::load_mcp_config(Some(cwd)); + let enabled = config.enabled_servers().count(); + let item = if config.issues.is_empty() { + check( + "mcp", + CheckStatus::Pass, + format!("{enabled} enabled MCP server(s); configuration is valid"), + None, + false, + ) + } else { + let mut item = check( + "mcp", + CheckStatus::Fail, + format!("{} MCP configuration issue(s)", config.issues.len()), + Some( + config + .issues + .iter() + .map(|issue| format!("{}: {}", issue.path.display(), issue.message)) + .collect::>() + .join("; "), + ), + false, + ); + item.remediation = Some( + "Run `deixic-code mcp status` and repair the reported configuration source.".to_owned(), + ); + item + }; + measured(item, started, verbose) +} + +fn hooks_config_check(cwd: &Path, verbose: bool) -> DoctorCheck { + let started = Instant::now(); + let item = match crate::hooks::load_hook_config(cwd) { + Ok(config) if !config.skipped_untrusted_paths.is_empty() => { + let mut item = check( + "hooks", + CheckStatus::Warning, + format!( + "{} effective hook(s); project hooks were skipped in an untrusted workspace", + config.hooks.len() + ), + Some( + config + .skipped_untrusted_paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "), + ), + false, + ); + item.remediation = Some("Inspect with `deixic-code hooks list --json`; grant workspace trust only after reviewing the hook files.".to_owned()); + item + } + Ok(config) => check( + "hooks", + CheckStatus::Pass, + format!( + "{} effective hook(s) from {} configuration source(s)", + config.hooks.len(), + config.source_paths.len() + ), + None, + false, + ), + Err(error) => { + let mut item = check( + "hooks", + CheckStatus::Fail, + "hook configuration failed to load", + Some(error.to_string()), + false, + ); + item.remediation = Some( + "Run `deixic-code hooks list --json` and repair the rejected source file." + .to_owned(), + ); + item + } + }; + measured(item, started, verbose) +} + +fn plugin_config_check(cwd: &Path, verbose: bool) -> DoctorCheck { + let started = Instant::now(); + let registry = crate::plugins::PluginRegistry::discover_for_workspace(cwd); + let item = if !registry.admission_errors().is_empty() { + let mut item = check( + "plugins", + CheckStatus::Fail, + format!( + "{} plugin admission error(s)", + registry.admission_errors().len() + ), + Some(registry.admission_errors().join("; ")), + false, + ); + item.remediation = Some( + "Run `deixic-code plugins list --json` and repair or remove the rejected plugin." + .to_owned(), + ); + item + } else if let Some(notice) = registry.untrusted_skip_notice() { + check( + "plugins", + CheckStatus::Warning, + format!("{} trusted plugin(s) loaded", registry.len()), + Some(notice), + false, + ) + } else { + check( + "plugins", + CheckStatus::Pass, + format!("{} trusted plugin(s) loaded", registry.len()), + None, + false, + ) + }; + measured(item, started, verbose) +} + +fn proxy_config_check(verbose: bool) -> DoctorCheck { + let started = Instant::now(); + let configured = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"] + .into_iter() + .filter_map(|name| std::env::var(name).ok().map(|value| (name, value))) + .filter(|(_, value)| !value.trim().is_empty()) + .collect::>(); + let invalid = configured + .iter() + .filter_map(|(name, value)| { + reqwest::Url::parse(value) + .err() + .map(|error| format!("{name}: {error}")) + }) + .collect::>(); + let item = if !invalid.is_empty() { + let mut item = check( + "proxy", + CheckStatus::Fail, + "proxy environment contains an invalid URL", + Some(invalid.join("; ")), + false, + ); + item.remediation = Some("Set HTTP_PROXY, HTTPS_PROXY, or ALL_PROXY to an absolute proxy URL, or remove the invalid value.".to_owned()); + item + } else if configured.is_empty() { + check( + "proxy", + CheckStatus::Pass, + "direct network path; no proxy configured", + None, + false, + ) + } else { + check( + "proxy", + CheckStatus::Pass, + format!("{} proxy variable(s) configured", configured.len()), + Some( + configured + .iter() + .map(|(name, _)| *name) + .collect::>() + .join(", "), + ), + false, + ) + }; + measured(item, started, verbose) +} + +fn certificate_config_check(verbose: bool) -> DoctorCheck { + let started = Instant::now(); + let configured = ["SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"] + .into_iter() + .filter_map(|name| { + std::env::var(name) + .ok() + .map(|value| (name, PathBuf::from(value))) + }) + .collect::>(); + let missing = configured + .iter() + .filter(|(_, path)| !path.is_file()) + .map(|(name, path)| format!("{name}={}", path.display())) + .collect::>(); + let item = if !missing.is_empty() { + let mut item = check( + "certs", + CheckStatus::Fail, + "custom certificate bundle is missing or not a file", + Some(missing.join("; ")), + false, + ); + item.remediation = Some( + "Install the certificate bundle or remove the stale environment override.".to_owned(), + ); + item + } else if configured.is_empty() { + check( + "certs", + CheckStatus::Pass, + "using the system certificate trust store", + None, + false, + ) + } else { + check( + "certs", + CheckStatus::Pass, + format!( + "{} custom certificate bundle override(s) are readable", + configured.len() + ), + None, + false, + ) + }; + measured(item, started, verbose) +} + +async fn http_health_check( + id: &str, + url: &str, + timeout: Duration, + verbose: bool, + remediation: &str, +) -> DoctorCheck { + let started = Instant::now(); + let parsed = match reqwest::Url::parse(url) { + Ok(url) if matches!(url.scheme(), "http" | "https") => url, + Ok(_) => { + let mut item = check( + id, + CheckStatus::Fail, + "health URL must use http or https", + None, + true, + ); + item.remediation = Some(remediation.to_owned()); + return measured(item, started, verbose); + } + Err(error) => { + let mut item = check( + id, + CheckStatus::Fail, + "health URL is invalid", + Some(error.to_string()), + true, + ); + item.remediation = Some(remediation.to_owned()); + return measured(item, started, verbose); + } + }; + let display = redacted_url(&parsed); + let result = match reqwest::Client::builder().timeout(timeout).build() { + Ok(client) => client.get(parsed).send().await, + Err(error) => Err(error), + }; + let item = match result { + Ok(response) if response.status().is_success() => check( + id, + CheckStatus::Pass, + format!("health endpoint responded at {display}"), + Some(format!("HTTP {}", response.status())), + true, + ), + Ok(response) => { + let mut item = check( + id, + CheckStatus::Fail, + format!("health endpoint rejected the probe at {display}"), + Some(format!("HTTP {}", response.status())), + true, + ); + item.remediation = Some(remediation.to_owned()); + item + } + Err(error) => { + let mut item = check( + id, + CheckStatus::Fail, + format!("health endpoint is unreachable at {display}"), + Some(if error.is_timeout() { + "request timed out".to_owned() + } else if error.is_connect() { + "connection failed".to_owned() + } else { + "request failed".to_owned() + }), + true, + ); + item.remediation = Some(remediation.to_owned()); + item + } + }; + measured(item, started, verbose) +} + +async fn daemon_check(force_live: bool, timeout: Duration, verbose: bool) -> DoctorCheck { + if !force_live { + return check( + "daemon", + CheckStatus::Skipped, + "daemon probe not requested", + Some("Run `deixic-code doctor --daemon` to probe the runtime gateway.".to_owned()), + true, + ); + } + let url = std::env::var("MAESTRO_DAEMON_URL").unwrap_or_else(|_| { + let host = std::env::var("MAESTRO_CONTROL_HOST").unwrap_or_else(|_| "127.0.0.1".to_owned()); + let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_owned()); + format!("http://{host}:{port}/healthz") + }); + http_health_check( + "daemon", + &url, + timeout, + verbose, + "Start `deixic-code serve`, verify its supervisor, and confirm MAESTRO_DAEMON_URL points at `/healthz`.", + ) + .await +} + +async fn runner_check(force_live: bool, timeout: Duration, verbose: bool) -> DoctorCheck { + if let Ok(url) = std::env::var("MAESTRO_RUNNER_HEALTH_URL") { + return http_health_check( + "runner", + &url, + timeout, + verbose, + "Restart the runner's external supervisor and verify its lease, credentials, and health URL.", + ) + .await; + } + if let Ok(raw_pid) = std::env::var("MAESTRO_HOSTED_RUNNER_PID") { + let started = Instant::now(); + let item = match raw_pid.parse::() { + Ok(pid) if pid > 0 && process_exists(pid) => check( + "runner", + CheckStatus::Pass, + format!("hosted runner process {pid} is alive"), + None, + true, + ), + Ok(pid) => { + let mut item = check( + "runner", + CheckStatus::Fail, + format!("hosted runner process {pid} is not alive"), + None, + true, + ); + item.remediation = Some("Restart the external runner supervisor; it owns replacement of the attested child generation.".to_owned()); + item + } + Err(error) => check( + "runner", + CheckStatus::Fail, + "MAESTRO_HOSTED_RUNNER_PID is invalid", + Some(error.to_string()), + true, + ), + }; + return measured(item, started, verbose); + } + let mut item = check( + "runner", + if force_live { CheckStatus::Fail } else { CheckStatus::Skipped }, + "runner health target is not configured", + Some("Set MAESTRO_RUNNER_HEALTH_URL or MAESTRO_HOSTED_RUNNER_PID in the runner service environment.".to_owned()), + force_live, + ); + item.remediation = Some("Expose the runner's authenticated health endpoint to its local supervisor and doctor process.".to_owned()); + item +} + +#[cfg(unix)] +fn process_exists(pid: u32) -> bool { + let result = unsafe { libc::kill(pid as libc::pid_t, 0) }; + result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(not(unix))] +fn process_exists(_pid: u32) -> bool { + false +} + +fn category_for_check(id: &str) -> &'static str { + match id { + "config" => "config", + "credential_mode" | "managed_setup" | "managed_inference" | "provider" | "auth_health" + | "codex_login" | "codex_app_server" => "auth", + "mcp" => "mcp", + "hooks" => "hooks", + "plugins" => "plugins", + "proxy" => "proxy", + "certs" => "certs", + "daemon" => "daemon", + "runner" => "runner", + "live_metadata" => "network", + _ => "config", + } +} + pub async fn build_report(model_override: Option<&str>, live: bool, cwd: &Path) -> DoctorReport { let requested = model_override .map(str::trim) @@ -866,13 +1324,53 @@ pub async fn run_doctor(args: &[String]) -> Result { let options = match parse_options(args) { Ok(options) => options, Err(error) if error.to_string() == "help" => { - println!("Usage: deixic-code doctor [--json] [--live] [--model ]"); + println!( + "Usage: deixic-code doctor [--json] [--live] [--verbose] [--timeout ] [--model ]\n\ + \nProbe groups (combine as needed):\n --connectivity --config --certs --proxy --auth --daemon --runner --mcp --hooks --plugins" + ); return Ok(0); } Err(error) => return Err(error), }; let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let report = build_report(options.model.as_deref(), options.live, &cwd).await; + let live = options.live || options.categories.contains("network"); + let mut report = build_report(options.model.as_deref(), live, &cwd).await; + let timeout = Duration::from_millis(options.timeout_ms); + report.checks.extend([ + mcp_config_check(&cwd, options.verbose), + hooks_config_check(&cwd, options.verbose), + plugin_config_check(&cwd, options.verbose), + proxy_config_check(options.verbose), + certificate_config_check(options.verbose), + daemon_check( + options.categories.contains("daemon") + || std::env::var_os("MAESTRO_DAEMON_URL").is_some(), + timeout, + options.verbose, + ) + .await, + runner_check( + options.categories.contains("runner") + || std::env::var_os("MAESTRO_RUNNER_HEALTH_URL").is_some() + || std::env::var_os("MAESTRO_HOSTED_RUNNER_PID").is_some(), + timeout, + options.verbose, + ) + .await, + ]); + if !options.categories.is_empty() { + report + .checks + .retain(|item| options.categories.contains(category_for_check(&item.id))); + } + report.ok = !report + .checks + .iter() + .any(|item| item.status == CheckStatus::Fail); + report.live_requested = report + .checks + .iter() + .any(|item| item.live && item.status != CheckStatus::Skipped); if options.json { println!("{}", serde_json::to_string_pretty(&report)?); } else { @@ -893,6 +1391,12 @@ pub async fn run_doctor(args: &[String]) -> Result { if let Some(detail) = &item.detail { println!(" {detail}"); } + if let Some(duration_ms) = item.duration_ms { + println!(" duration: {duration_ms}ms"); + } + if let Some(remediation) = &item.remediation { + println!(" fix: {remediation}"); + } } } Ok(report_exit_code(&report)) @@ -986,11 +1490,25 @@ mod tests { let args = vec![ "--json".into(), "--live".into(), + "--verbose".into(), + "--timeout=2500".into(), + "--network".into(), + "--daemon".into(), + "--hooks".into(), "--model=openai/gpt-4o".into(), ]; let options = parse_options(&args).expect("options"); - assert!(options.json && options.live); + assert!(options.json && options.live && options.verbose); + assert_eq!(options.timeout_ms, 2500); + assert_eq!( + options.categories, + ["network", "daemon", "hooks"] + .into_iter() + .map(str::to_owned) + .collect() + ); assert_eq!(options.model.as_deref(), Some("openai/gpt-4o")); + assert!(parse_options(&["--timeout=0".into()]).is_err()); } #[tokio::test] @@ -1005,6 +1523,42 @@ mod tests { ); } + #[tokio::test] + async fn operational_health_probe_reports_latency_and_redacts_credentials() { + let (base_url, server) = test_server(200, "ok", Duration::ZERO).await; + let url = format!( + "http://user:password@{}/healthz?token=secret", + base_url.trim_start_matches("http://") + ); + let item = http_health_check( + "daemon", + &url, + Duration::from_secs(1), + true, + "restart daemon", + ) + .await; + assert_eq!(item.status, CheckStatus::Pass); + assert!(item.duration_ms.is_some()); + let serialized = serde_json::to_string(&item).expect("serialize check"); + assert!(!serialized.contains("password")); + assert!(!serialized.contains("secret")); + server.await.expect("test server"); + + let failed = http_health_check( + "daemon", + "http://user:password@127.0.0.1:1/healthz?token=secret", + Duration::from_millis(50), + true, + "restart daemon", + ) + .await; + assert_eq!(failed.status, CheckStatus::Fail); + let serialized = serde_json::to_string(&failed).expect("serialize failed check"); + assert!(!serialized.contains("password")); + assert!(!serialized.contains("secret")); + } + #[test] fn offline_report_reports_byok_ready_without_identity() { let _lock = crate::config::test_process_env_lock(); diff --git a/packages/local-host-rs/src/headless/remote_transport/tests.rs b/packages/local-host-rs/src/headless/remote_transport/tests.rs index 648c5f09e..581773869 100644 --- a/packages/local-host-rs/src/headless/remote_transport/tests.rs +++ b/packages/local-host-rs/src/headless/remote_transport/tests.rs @@ -1636,6 +1636,17 @@ async fn remote_transport_classifies_stream_404_as_retryable() { return; } + if path.starts_with("/api/headless/sessions/") && path.ends_with("/messages") { + write_http_response( + &mut socket, + "HTTP/1.1 200 OK", + "application/json", + r#"{"success":true}"#, + ) + .await; + return; + } + if path.starts_with("/api/headless/sessions/") && path.ends_with("/disconnect") { write_http_response( &mut socket, @@ -1680,15 +1691,18 @@ async fn remote_transport_classifies_stream_404_as_retryable() { .recv_incoming() .await .expect_err("stream 404 should fail"); - assert!(matches!( - error, - AsyncTransportError::RemoteStatus { - status: 404, - retryable: true, - kind: RemoteErrorKind::StaleSubscriber, - .. - } - )); + assert!( + matches!( + &error, + AsyncTransportError::RemoteStatus { + status: 404, + retryable: true, + kind: RemoteErrorKind::StaleSubscriber, + .. + } + ), + "unexpected stream error: {error:?}" + ); transport.shutdown().expect("shutdown"); } diff --git a/packages/local-host-rs/src/headless/supervisor.rs b/packages/local-host-rs/src/headless/supervisor.rs index cf484c014..1b965233b 100644 --- a/packages/local-host-rs/src/headless/supervisor.rs +++ b/packages/local-host-rs/src/headless/supervisor.rs @@ -14,7 +14,7 @@ use std::sync::OnceLock; use std::sync::mpsc::{self as std_mpsc, SyncSender}; use std::time::{Duration, Instant}; -use rand::Rng as _; +use rand::RngExt as _; use tokio::sync::{Notify, mpsc}; use tokio::task::JoinHandle; // Note: interval/timeout available for future health checking diff --git a/packages/local-host-rs/src/hooks/integration.rs b/packages/local-host-rs/src/hooks/integration.rs index 5e4e61a76..1b748c062 100644 --- a/packages/local-host-rs/src/hooks/integration.rs +++ b/packages/local-host-rs/src/hooks/integration.rs @@ -1424,32 +1424,26 @@ impl IntegratedHookSystem { /// Reload all hooks from config files pub fn reload(&mut self) -> Result { - let lua_reloaded = self - .lua_executor - .as_mut() - .map(super::lua::LuaHookExecutor::reload) - .transpose()? - .unwrap_or(0); - let wasm_reloaded = self - .wasm_executor - .as_mut() - .map(super::wasm::WasmHookExecutor::reload) - .transpose()? - .unwrap_or(0); - - // Reload config - if let Ok(config) = load_hook_config(Path::new(&self.cwd)) { - self.enabled = config.settings.enabled; - self.timeout = Duration::from_millis(config.settings.timeout_ms); - self.log_file = config.settings.log_file.clone(); - if let Some(wasm) = self.wasm_executor.as_mut() { - wasm.set_timeout(self.timeout); - } - } - + let config = load_hook_config(Path::new(&self.cwd))?; + let mut reloaded = Self::from_config_result(&self.cwd, Ok(config)); + + // Runtime context belongs to the active session, not the files being + // reloaded. Preserve it while replacing every configured hook backend. + reloaded.session_id.clone_from(&self.session_id); + reloaded.transcript_path.clone_from(&self.transcript_path); + reloaded.transcript_checkpoint_size = self.transcript_checkpoint_size; + reloaded.organization_id.clone_from(&self.organization_id); + reloaded.workspace_id.clone_from(&self.workspace_id); + reloaded.session_history.clone_from(&self.session_history); + reloaded.session_start = self.session_start; + reloaded.turn_count = self.turn_count; + reloaded.metrics.clone_from(&self.metrics); + + let stats = reloaded.stats(); + *self = reloaded; Ok(ReloadResult { - lua_scripts: lua_reloaded, - wasm_plugins: wasm_reloaded, + lua_scripts: stats.lua_scripts, + wasm_plugins: stats.wasm_plugins, }) } diff --git a/packages/local-host-rs/src/hosted_runner/workload_identity.rs b/packages/local-host-rs/src/hosted_runner/workload_identity.rs index 6ea1c0815..29d208eb1 100644 --- a/packages/local-host-rs/src/hosted_runner/workload_identity.rs +++ b/packages/local-host-rs/src/hosted_runner/workload_identity.rs @@ -1,7 +1,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{DateTime, Duration, Utc}; use futures::StreamExt as _; -use rand::Rng as _; +use rand::RngExt as _; use rcgen::{CertificateParams, KeyPair}; use rustls::{ DigitallySignedStruct, DistinguishedName, Error as TlsError, SignatureScheme, diff --git a/packages/local-host-rs/src/mcp/oauth.rs b/packages/local-host-rs/src/mcp/oauth.rs index 732c8d345..1b4ca6f89 100644 --- a/packages/local-host-rs/src/mcp/oauth.rs +++ b/packages/local-host-rs/src/mcp/oauth.rs @@ -8,7 +8,6 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use base64::Engine as _; -use rand::RngCore; use reqwest::header::WWW_AUTHENTICATE; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -421,7 +420,7 @@ async fn await_callback(listener: TcpListener, expected_state: &str) -> Result String { let mut random = vec![0_u8; bytes]; - rand::rng().fill_bytes(&mut random); + rand::fill(&mut random); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random) } diff --git a/packages/local-host-rs/src/mission_cli.rs b/packages/local-host-rs/src/mission_cli.rs index be5e9586e..f6a7959c2 100644 --- a/packages/local-host-rs/src/mission_cli.rs +++ b/packages/local-host-rs/src/mission_cli.rs @@ -2670,6 +2670,7 @@ mod tests { ..child.clone() }; let submission = CodingCompletionSubmission { + computer_proof: None, task_id: contract.task_id.clone(), work_id: child.work_id.clone(), repository_id: contract.repository_id.clone(), diff --git a/packages/local-host-rs/src/model_catalog_data.json b/packages/local-host-rs/src/model_catalog_data.json index 26c92fc71..3063a11f9 100644 --- a/packages/local-host-rs/src/model_catalog_data.json +++ b/packages/local-host-rs/src/model_catalog_data.json @@ -1,5 +1,5 @@ { - "generated_at": 1789716653, + "generated_at": 1789802841, "source": "https://models.dev/api.json+https://openrouter.ai/api/v1/models", "models": [ { @@ -1324,7 +1324,7 @@ "reasoning": true, "streaming": true, "context_tokens": 1048576, - "output_tokens": 384000 + "output_tokens": 393216 }, "verification": { "state": "catalog", @@ -1552,7 +1552,7 @@ "reasoning": true, "streaming": true, "context_tokens": 1310720, - "output_tokens": 235929 + "output_tokens": 131072 }, "verification": { "state": "catalog", @@ -2578,7 +2578,7 @@ "reasoning": true, "streaming": true, "context_tokens": 1048576, - "output_tokens": 393216 + "output_tokens": 384000 }, "verification": { "state": "catalog", @@ -2597,7 +2597,7 @@ "reasoning": true, "streaming": true, "context_tokens": 1048576, - "output_tokens": 384000 + "output_tokens": 393216 }, "verification": { "state": "catalog", @@ -4706,7 +4706,7 @@ "reasoning": true, "streaming": true, "context_tokens": 262144, - "output_tokens": 32768 + "output_tokens": 182520 }, "verification": { "state": "catalog", @@ -4782,7 +4782,7 @@ "reasoning": true, "streaming": true, "context_tokens": 262144, - "output_tokens": 131072 + "output_tokens": 235929 }, "verification": { "state": "catalog", @@ -5951,7 +5951,7 @@ "id": "openrouter/auto-beta", "name": "Auto Router (Beta)", "provider": "openrouter", - "description": "Auto Router (Beta) is a task-aware router from OpenRouter. It classifies each request, then routes it the [most…", + "description": "The experimental version of our Auto Router where we test new improvements. Use it to get the latest and greatest…", "capabilities": { "protocol": "openai-chat", "tools": true, @@ -6230,6 +6230,25 @@ "source": "openrouter" } }, + { + "id": "prism-ml/ternary-bonsai-2-27b", + "name": "PrismML: Ternary Bonsai 2 27B", + "provider": "openrouter", + "description": "Bonsai 2 27B is a 27B-parameter reasoning model from PrismML derived from Qwen3.8-27B. It supports coding,…", + "capabilities": { + "protocol": "openai-chat", + "tools": true, + "vision": true, + "reasoning": true, + "streaming": true, + "context_tokens": 262144, + "output_tokens": 32768 + }, + "verification": { + "state": "catalog", + "source": "openrouter" + } + }, { "id": "qwen/qwen-2.5-72b-instruct", "name": "Qwen2.5 72B Instruct", @@ -8180,7 +8199,7 @@ "reasoning": true, "streaming": true, "context_tokens": 1048576, - "output_tokens": 163840 + "output_tokens": 131072 }, "verification": { "state": "catalog", @@ -8218,7 +8237,7 @@ "reasoning": true, "streaming": true, "context_tokens": 1310720, - "output_tokens": 943717 + "output_tokens": 131072 }, "verification": { "state": "catalog", @@ -8244,6 +8263,25 @@ "source": "openrouter" } }, + { + "id": "z-ai/glm-5.3-flashx", + "name": "Z.ai: GLM 5.3 FlashX", + "provider": "openrouter", + "description": "GLM-5.3-FlashX is the high-speed variant of Z.ai's GLM-5.3-Flash, a native multimodal model delivering inference…", + "capabilities": { + "protocol": "openai-chat", + "tools": true, + "vision": true, + "reasoning": true, + "streaming": true, + "context_tokens": 1048576, + "output_tokens": 131072 + }, + "verification": { + "state": "catalog", + "source": "openrouter" + } + }, { "id": "z-ai/glm-5v-turbo", "name": "Z.ai: GLM 5V Turbo", diff --git a/packages/local-host-rs/src/openai_cli.rs b/packages/local-host-rs/src/openai_cli.rs index 5e83045a8..b94d5960b 100644 --- a/packages/local-host-rs/src/openai_cli.rs +++ b/packages/local-host-rs/src/openai_cli.rs @@ -8,7 +8,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use rand::RngCore; use reqwest::{Client, StatusCode}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -390,8 +389,8 @@ async fn exchange_id_token_for_api_key(client: &Client, id_token: &str) -> Resul fn login_request() -> Result { let mut verifier_bytes = [0_u8; 32]; let mut state_bytes = [0_u8; 32]; - rand::rng().fill_bytes(&mut verifier_bytes); - rand::rng().fill_bytes(&mut state_bytes); + rand::fill(&mut verifier_bytes); + rand::fill(&mut state_bytes); let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); let state = URL_SAFE_NO_PAD.encode(state_bytes); diff --git a/packages/local-host-rs/src/private_code_authority.rs b/packages/local-host-rs/src/private_code_authority.rs index 3cc1a71be..102069c9a 100644 --- a/packages/local-host-rs/src/private_code_authority.rs +++ b/packages/local-host-rs/src/private_code_authority.rs @@ -296,6 +296,9 @@ mod tests { Err(error) => panic!("fixture accept: {error}"), } }; + // Accepted sockets inherit O_NONBLOCK from this listener on macOS. + // Rustls expects a blocking stream in this fixture. + stream.set_nonblocking(false).unwrap(); stream.set_read_timeout(Some(TIMEOUT)).unwrap(); let connection = rustls::ServerConnection::new(Arc::new(tls)).unwrap(); let mut stream = rustls::StreamOwned::new(connection, stream); @@ -303,10 +306,14 @@ mod tests { let mut buffer = [0; 4096]; loop { match stream.read(&mut buffer) { - Ok(0) | Err(_) => { + Ok(0) => { assert!(!trusted); return; } + Err(error) => { + assert!(!trusted, "trusted fixture TLS read failed: {error:?}"); + return; + } Ok(count) => request.extend_from_slice(&buffer[..count]), } if request.windows(4).any(|part| part == b"\r\n\r\n") { @@ -344,7 +351,8 @@ mod tests { } for (response, expected) in cases { let (config, server) = fixture(response.to_string(), "200 OK", true); - assert_eq!(verify(&config, &metadata(), &env).is_ok(), expected); + let result = verify(&config, &metadata(), &env); + assert_eq!(result.is_ok(), expected, "{result:?}"); server.join().unwrap(); } for (status, trusted, body) in [ diff --git a/packages/local-host-rs/src/safety/bash_analyzer.rs b/packages/local-host-rs/src/safety/bash_analyzer.rs index 405916aab..daff78a33 100644 --- a/packages/local-host-rs/src/safety/bash_analyzer.rs +++ b/packages/local-host-rs/src/safety/bash_analyzer.rs @@ -3,7 +3,9 @@ //! Analyzes shell commands with a bounded tree-sitter Bash parse and determines risk. use std::collections::HashSet; -use tree_sitter::{Node, Parser}; +use std::ops::ControlFlow; +use std::time::{Duration, Instant}; +use tree_sitter::{Node, ParseOptions, Parser}; const MAX_BASH_SOURCE_BYTES: usize = 1_048_576; const MAX_BASH_NODES: usize = 50_000; @@ -431,9 +433,23 @@ fn parse_commands(input: &str) -> Result { parser .set_language(&tree_sitter_bash::LANGUAGE.into()) .map_err(|_| ())?; - #[allow(deprecated)] - parser.set_timeout_micros(50_000); - let tree = parser.parse(input, None).ok_or(())?; + let started_at = Instant::now(); + let timeout = Duration::from_millis(50); + let mut cancel_after_timeout = |_: &tree_sitter::ParseState| { + if started_at.elapsed() >= timeout { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }; + let bytes = input.as_bytes(); + let tree = parser + .parse_with_options( + &mut |offset, _| bytes.get(offset..).unwrap_or_default(), + None, + Some(ParseOptions::new().progress_callback(&mut cancel_after_timeout)), + ) + .ok_or(())?; if tree.root_node().has_error() { return Err(()); } diff --git a/packages/local-host-rs/src/safety/disconnected_deployment.rs b/packages/local-host-rs/src/safety/disconnected_deployment.rs new file mode 100644 index 000000000..d3c8b7414 --- /dev/null +++ b/packages/local-host-rs/src/safety/disconnected_deployment.rs @@ -0,0 +1,77 @@ +//! Offline inventory of the administrator-managed disconnected profile. + +use serde::Serialize; + +use super::policy::{ManagedPolicyMetadata, disconnected_policy_verified}; + +/// Read-only projection of verified policy. This is configuration evidence, not +/// a reachability probe, tool grant, network sandbox or deployment qualification. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DisconnectedDeploymentContract { + pub schema_version: u32, + pub policy: ManagedPolicyMetadata, + pub model_routes: Vec, + pub customer_authority: Option, + /// Declared profile behavior, not an observation of traffic or worker state. + pub declared_disabled_first_party_services: Vec<&'static str>, + pub local_sessions: &'static str, + pub enrolled_tools: &'static str, + pub external_process_network: &'static str, + pub customer_telemetry: &'static str, + pub deployment_qualification: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DisconnectedModelDestination { + pub model: String, + pub endpoint: String, + pub credential_required: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DisconnectedAuthorityDestination { + pub endpoint: String, + pub custom_ca_configured: bool, +} + +/// Never reads credentials, resolves DNS, probes a server or loads repository +/// configuration. All destinations come from the same verified signed envelope. +pub fn disconnected_deployment_contract() -> Result { + let (profile, metadata) = disconnected_policy_verified()? + .ok_or("No administrator-managed disconnected profile is installed")?; + Ok(DisconnectedDeploymentContract { + schema_version: 1, + policy: metadata, + model_routes: profile + .routes + .into_iter() + .map(|route| DisconnectedModelDestination { + model: route.model, + endpoint: route.endpoint, + credential_required: route.credential_env.is_some(), + }) + .collect(), + customer_authority: profile + .authority + .map(|authority| DisconnectedAuthorityDestination { + endpoint: authority.endpoint, + custom_ca_configured: authority.ca_pem.is_some(), + }), + declared_disabled_first_party_services: vec![ + "identity", + "session_history_capture", + "telemetry_outbox", + "updates", + "remote_catalog_refresh", + "hosted_commands", + ], + local_sessions: "existing_local_store", + enrolled_tools: "customer_authority_required", + external_process_network: "operator_enforced", + customer_telemetry: "separately_configured", + deployment_qualification: "not_assessed", + }) +} diff --git a/packages/local-host-rs/src/safety/mod.rs b/packages/local-host-rs/src/safety/mod.rs index c12ca8b88..6ac22bfe7 100644 --- a/packages/local-host-rs/src/safety/mod.rs +++ b/packages/local-host-rs/src/safety/mod.rs @@ -53,6 +53,7 @@ mod bash_analyzer; mod dangerous_patterns; +mod disconnected_deployment; mod firewall; pub mod guardian; mod path_containment; @@ -72,6 +73,7 @@ pub use dangerous_patterns::{ DangerousPattern, PatternMatch, Severity, check_dangerous_patterns, has_high_severity_pattern, most_severe_match, }; +pub use disconnected_deployment::disconnected_deployment_contract; pub use firewall::{ActionFirewall, FirewallContext, FirewallVerdict}; pub use maestro_runtime::agent::denial_memory::{DenialMemory, MAX_DENIAL_TARGET_CHARS}; pub use maestro_runtime::agent::workflow_state::{ diff --git a/packages/local-host-rs/src/safety/policy.rs b/packages/local-host-rs/src/safety/policy.rs index 46882650f..31d666221 100644 --- a/packages/local-host-rs/src/safety/policy.rs +++ b/packages/local-host-rs/src/safety/policy.rs @@ -395,7 +395,7 @@ pub fn disconnected_authority() .and_then(|(profile, metadata)| profile.authority.map(|authority| (authority, metadata)))) } -fn disconnected_policy_verified() +pub(super) fn disconnected_policy_verified() -> Result, String> { let Some(managed) = load_managed_policy(false)? else { return Ok(None); @@ -2143,6 +2143,9 @@ fn is_private_ip(ip: &IpAddr) -> bool { #[cfg(test)] mod tests { + mod deployment_tests; + use crate::safety::disconnected_deployment_contract; + use super::*; use ring::signature::KeyPair; @@ -2335,30 +2338,6 @@ mod tests { } } - #[test] - fn disconnected_policy_uses_signed_machine_routes_and_rejects_tampering() { - let _lock = test_env_guard(); - let fixture = MachineFixture::new(); - fixture.install(private_profile()); - assert!(vendor_network_disabled()); - assert!( - disconnected_route("ollama/customer-model") - .unwrap() - .is_some() - ); - assert!(disconnected_route("openai/gpt-5.5").is_err()); - assert!(require_vendor_network().is_err()); - let path = fixture.directory.path().join("managed-policy.json"); - let mut envelope: ManagedPolicyEnvelope = - serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - envelope.policy.disconnected.as_mut().unwrap().routes[0].endpoint = - "https://api.openai.com/v1".into(); - std::fs::write(&path, serde_json::to_vec(&envelope).unwrap()).unwrap(); - assert!(disconnected_policy().is_err()); - assert!(vendor_network_disabled()); - assert!(check_tool_allowed("read").is_some()); - } - #[test] fn disconnected_admission_never_verifies_stored_identity() { let _env = crate::config::test_process_env_lock(); @@ -2540,6 +2519,11 @@ mod tests { envelope.expires_at = 2; std::fs::write(&path, serde_json::to_vec(&envelope).unwrap()).unwrap(); assert!(disconnected_policy().unwrap_err().contains("expired")); + assert!( + disconnected_deployment_contract() + .unwrap_err() + .contains("expired") + ); assert!(vendor_network_disabled()); assert!(check_model_allowed("ollama/customer-model").is_some()); assert!(check_tool_allowed("read").is_some()); diff --git a/packages/local-host-rs/src/safety/policy/tests/deployment_tests.rs b/packages/local-host-rs/src/safety/policy/tests/deployment_tests.rs new file mode 100644 index 000000000..cfc44c0d3 --- /dev/null +++ b/packages/local-host-rs/src/safety/policy/tests/deployment_tests.rs @@ -0,0 +1,85 @@ +use super::*; +use crate::safety::disconnected_deployment_contract; + +#[test] +fn disconnected_deployment_contract_reports_signed_destinations_without_credentials() { + let _lock = test_env_guard(); + let fixture = MachineFixture::new(); + let mut profile = private_profile(); + profile.routes[0].credential_env = Some("CUSTOMER_MODEL_API_KEY".into()); + profile.authority = Some(DisconnectedAuthority { + endpoint: "https://identity.customer.example".into(), + credential_env: "CUSTOMER_CODE_ACCESS_TOKEN".into(), + ca_pem: None, + }); + fixture.install(profile); + // Deliberately no credential provisioning or customer server. Inspection + // must work before an operator grants credentials or network access. + let contract = disconnected_deployment_contract().unwrap(); + let json = serde_json::to_value(contract).unwrap(); + assert_eq!(json["schemaVersion"], 1); + assert_eq!(json["policy"]["policyVersion"], 1); + assert_eq!( + json["modelRoutes"][0]["endpoint"], + "http://127.0.0.1:11434/v1" + ); + assert_eq!(json["modelRoutes"][0]["credentialRequired"], true); + assert_eq!( + json["customerAuthority"]["endpoint"], + "https://identity.customer.example" + ); + assert_eq!(json["enrolledTools"], "customer_authority_required"); + assert_eq!(json["externalProcessNetwork"], "operator_enforced"); + assert_eq!(json["deploymentQualification"], "not_assessed"); + let serialized = json.to_string(); + assert!(!serialized.contains("CUSTOMER_MODEL_API_KEY")); + assert!(!serialized.contains("CUSTOMER_CODE_ACCESS_TOKEN")); + assert!(!serialized.contains("caPem")); + assert!(vendor_network_disabled()); +} + +#[test] +fn disconnected_deployment_contract_requires_valid_installed_policy() { + let _lock = test_env_guard(); + let fixture = MachineFixture::new(); + assert!(disconnected_deployment_contract().is_err()); + fixture.install(private_profile()); + assert!( + disconnected_deployment_contract() + .unwrap() + .customer_authority + .is_none() + ); + let path = fixture.directory.path().join("managed-policy.json"); + let mut envelope: ManagedPolicyEnvelope = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + envelope.policy.disconnected.as_mut().unwrap().routes[0].endpoint = + "https://unapproved.customer.example/v1".into(); + std::fs::write(&path, serde_json::to_vec(&envelope).unwrap()).unwrap(); + assert!(disconnected_deployment_contract().is_err()); + assert!(vendor_network_disabled()); +} + +#[test] +fn disconnected_policy_uses_signed_machine_routes_and_rejects_tampering() { + let _lock = test_env_guard(); + let fixture = MachineFixture::new(); + fixture.install(private_profile()); + assert!(vendor_network_disabled()); + assert!( + disconnected_route("ollama/customer-model") + .unwrap() + .is_some() + ); + assert!(disconnected_route("openai/gpt-5.5").is_err()); + assert!(require_vendor_network().is_err()); + let path = fixture.directory.path().join("managed-policy.json"); + let mut envelope: ManagedPolicyEnvelope = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + envelope.policy.disconnected.as_mut().unwrap().routes[0].endpoint = + "https://api.openai.com/v1".into(); + std::fs::write(&path, serde_json::to_vec(&envelope).unwrap()).unwrap(); + assert!(disconnected_policy().is_err()); + assert!(vendor_network_disabled()); + assert!(check_tool_allowed("read").is_some()); +} diff --git a/packages/local-host-rs/src/telemetry/staged_rollout.rs b/packages/local-host-rs/src/telemetry/staged_rollout.rs index 3db84f482..185ac605b 100644 --- a/packages/local-host-rs/src/telemetry/staged_rollout.rs +++ b/packages/local-host-rs/src/telemetry/staged_rollout.rs @@ -12,7 +12,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use fd_lock::RwLock as FileLock; -use rand::Rng as _; +use rand::RngExt as _; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; diff --git a/packages/local-host-rs/src/telemetry/wide_events.rs b/packages/local-host-rs/src/telemetry/wide_events.rs index 1de5517ad..64cd63574 100644 --- a/packages/local-host-rs/src/telemetry/wide_events.rs +++ b/packages/local-host-rs/src/telemetry/wide_events.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::time::Instant; use chrono::Utc; -use rand::Rng; +use rand::RngExt; use serde::{Deserialize, Serialize}; use uuid::Uuid; diff --git a/packages/local-host-rs/src/terminal_info.rs b/packages/local-host-rs/src/terminal_info.rs index 59685dd56..a8ec922e8 100644 --- a/packages/local-host-rs/src/terminal_info.rs +++ b/packages/local-host-rs/src/terminal_info.rs @@ -141,10 +141,15 @@ fn detect_terminal_name_version() -> (String, Option) { } // Fallback to TERM - let term = std::env::var("TERM").unwrap_or_else(|_| "unknown".to_string()); + let term = fallback_terminal_name(std::env::var("TERM").ok()); (term, None) } +fn fallback_terminal_name(term: Option) -> String { + term.filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + /// Sanitize a string for use in HTTP header values. fn sanitize_header_value(value: &str) -> String { value @@ -391,6 +396,17 @@ mod tests { assert!(!info.name.is_empty()); } + #[test] + fn empty_term_falls_back_to_unknown() { + assert_eq!(fallback_terminal_name(None), "unknown"); + assert_eq!(fallback_terminal_name(Some(String::new())), "unknown"); + assert_eq!(fallback_terminal_name(Some(" ".into())), "unknown"); + assert_eq!( + fallback_terminal_name(Some("xterm-256color".into())), + "xterm-256color" + ); + } + #[test] fn test_user_agent_format() { let info = TerminalInfo { diff --git a/packages/local-host-rs/src/tools/bash/shield.rs b/packages/local-host-rs/src/tools/bash/shield.rs index da39d58d0..283449f6d 100644 --- a/packages/local-host-rs/src/tools/bash/shield.rs +++ b/packages/local-host-rs/src/tools/bash/shield.rs @@ -1,11 +1,13 @@ //! Default-on publication guard for literal Git commit/push calls in BashTool. //! Scans stay local. Findings contain locations and kinds, never source excerpts. use std::collections::HashMap; +use std::ops::ControlFlow; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use tokio::io::AsyncReadExt; +use tree_sitter::ParseOptions; use zeroize::Zeroizing; use crate::agent::credential_store::{publication_secret_kind, redact_credentials_in_json}; @@ -32,9 +34,23 @@ fn publication(command: &str, cwd: &Path) -> Result> { parser .set_language(&tree_sitter_bash::LANGUAGE.into()) .context(HELP)?; - #[allow(deprecated)] - parser.set_timeout_micros(50_000); - let tree = parser.parse(command, None).context(HELP)?; + let started_at = Instant::now(); + let timeout = Duration::from_millis(50); + let mut cancel_after_timeout = |_: &tree_sitter::ParseState| { + if started_at.elapsed() >= timeout { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }; + let bytes = command.as_bytes(); + let tree = parser + .parse_with_options( + &mut |offset, _| bytes.get(offset..).unwrap_or_default(), + None, + Some(ParseOptions::new().progress_callback(&mut cancel_after_timeout)), + ) + .context(HELP)?; let root = tree.root_node(); let mut stack = vec![root]; let mut candidate = None; diff --git a/packages/local-host-rs/src/tools/registry/coding_task.rs b/packages/local-host-rs/src/tools/registry/coding_task.rs index 5c4e360f3..63612b791 100644 --- a/packages/local-host-rs/src/tools/registry/coding_task.rs +++ b/packages/local-host-rs/src/tools/registry/coding_task.rs @@ -816,6 +816,7 @@ impl ToolExecutor { let (behavior, behavior_record) = self.collect_coding_validator(state, CodingValidationRole::Behavior)?; let submission = CodingCompletionSubmission { + computer_proof: None, task_id: state.contract.task_id.clone(), work_id: state.work_id.clone(), repository_id: state.contract.repository_id.clone(), diff --git a/packages/local-host-rs/src/ui_prefs.rs b/packages/local-host-rs/src/ui_prefs.rs index fae7d1b21..2b859a301 100644 --- a/packages/local-host-rs/src/ui_prefs.rs +++ b/packages/local-host-rs/src/ui_prefs.rs @@ -85,7 +85,8 @@ impl UiPrefs { match self.dex_personality.as_deref() { Some("quiet") => DexPersonality::Quiet, Some("expressive") => DexPersonality::Expressive, - _ => DexPersonality::Standard, + Some("standard") => DexPersonality::Standard, + _ => DexPersonality::Quiet, } } @@ -215,7 +216,7 @@ mod tests { assert_eq!(loaded.footer_style(), FooterStyle::Solo); } let legacy: UiPrefs = serde_json::from_str(r#"{"footerStyle":"solo"}"#).unwrap(); - assert_eq!(legacy.dex_personality(), DexPersonality::Standard); + assert_eq!(legacy.dex_personality(), DexPersonality::Quiet); assert_eq!(legacy.animations, None); } #[test] diff --git a/packages/maestro-rs/src/cli.rs b/packages/maestro-rs/src/cli.rs index 7897ed31f..7f8ce87d9 100644 --- a/packages/maestro-rs/src/cli.rs +++ b/packages/maestro-rs/src/cli.rs @@ -13,7 +13,11 @@ use std::ffi::OsString; /// variant removes that dead computation and the duplicated table. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Command { - Serve { port: Option }, + Serve { + port: Option, + parent_pid: Option, + liveness_fd: Option, + }, Forward, Help, Version, @@ -45,6 +49,8 @@ where } if first == Some("serve") { let mut port = None; + let mut parent_pid = None; + let mut liveness_fd = None; let mut index = 1; while index < strings.len() { let argument = strings[index].as_ref(); @@ -62,6 +68,34 @@ where .parse::() .map_err(|_| format!("invalid serve port: {value}"))?, ); + } else if argument == "--parent-pid" { + index += 1; + let value = strings.get(index).ok_or("--parent-pid requires a value")?; + parent_pid = Some( + value + .parse::() + .map_err(|_| format!("invalid parent pid: {value}"))?, + ); + } else if let Some(value) = argument.strip_prefix("--parent-pid=") { + parent_pid = Some( + value + .parse::() + .map_err(|_| format!("invalid parent pid: {value}"))?, + ); + } else if argument == "--liveness-fd" { + index += 1; + let value = strings.get(index).ok_or("--liveness-fd requires a value")?; + liveness_fd = Some( + value + .parse::() + .map_err(|_| format!("invalid liveness fd: {value}"))?, + ); + } else if let Some(value) = argument.strip_prefix("--liveness-fd=") { + liveness_fd = Some( + value + .parse::() + .map_err(|_| format!("invalid liveness fd: {value}"))?, + ); } else { return Err(format!( "`maestro serve` does not accept prompt arguments or option `{argument}`" @@ -69,7 +103,20 @@ where } index += 1; } - return Ok(Command::Serve { port }); + if parent_pid.is_some() && liveness_fd.is_some() { + return Err("--parent-pid and --liveness-fd are mutually exclusive".to_owned()); + } + if parent_pid == Some(0) { + return Err("--parent-pid must be a positive process id".to_owned()); + } + if liveness_fd.is_some_and(|fd| fd < 0) { + return Err("--liveness-fd must be a non-negative file descriptor".to_owned()); + } + return Ok(Command::Serve { + port, + parent_pid, + liveness_fd, + }); } // Every other invocation (interactive TUI, `exec`/`print`/`-p`, diff --git a/packages/maestro-rs/src/main.rs b/packages/maestro-rs/src/main.rs index 91286e820..c63ebbaa6 100644 --- a/packages/maestro-rs/src/main.rs +++ b/packages/maestro-rs/src/main.rs @@ -11,7 +11,7 @@ fn pre_main_hardening() { maestro_tui::process_hardening::pre_main_hardening(); } -const HELP: &str = "Deixic Code\n\nUsage:\n deixic-code [options] [prompt]\n deixic-code exec \n deixic-code -w [prompt]\n deixic-code doctor [--json] [--live] [--model ]\n deixic-code setup [--json] [--live] [--model ]\n deixic-code --headless\n deixic-code serve [--port ]\n deixic-code hosted-runner [options]\n\nCommon commands:\n deixic-code setup Check authentication and show the next setup step\n deixic-code config Inspect or change Deixic Code configuration\n deixic-code experiments Review or change tool experiment participation\n deixic-code models List available models and providers\n deixic-code sessions List, export, or import saved sessions\n deixic-code scenario Run or inspect scripted scenarios\n deixic-code run Inspect or replay a saved session\n\nAdvanced commands:\n deixic-code diagnostics Project compiler JSONL, optionally against a prior observation\n deixic-code codex Manage Codex authentication and app-server access\n deixic-code mcp Configure MCP servers\n deixic-code plugins Inspect and manage plugins\n deixic-code connections Manage API keys and delegated accounts\n deixic-code init Register a Deixic agent integration\n\nCompatibility:\n maestro remains available as an alias; MAESTRO_* variables and .maestro paths are unchanged.\n\nOptions:\n -w, --worktree Run the session in a new git worktree at ../-wt-\n on a new branch; interactive worktrees and branches are kept\n exec removes only clean worktrees with no new commits\n\nThe product runtime is native Rust; no Node.js or Bun runtime is required."; +const HELP: &str = "Deixic Code\n\nUsage:\n deixic-code [options] [prompt]\n deixic-code exec \n deixic-code -w [prompt]\n deixic-code doctor [--json] [--live] [--verbose] [--timeout ] [probe groups]\n deixic-code setup [--json] [--live] [--model ]\n deixic-code --list-tools [--json]\n deixic-code --headless\n deixic-code serve [--port ] [--parent-pid | --liveness-fd ]\n deixic-code hosted-runner [options]\n\nCommon commands:\n deixic-code setup Check authentication and show the next setup step\n deixic-code config Inspect or change Deixic Code configuration\n deixic-code experiments Review or change tool experiment participation\n deixic-code models List available models and providers\n deixic-code sessions List, export, or import saved sessions\n deixic-code scenario Run or inspect scripted scenarios\n deixic-code run Inspect or replay a saved session\n\nAdvanced commands:\n deixic-code diagnostics Project compiler JSONL, optionally against a prior observation\n deixic-code codex Manage Codex authentication and app-server access\n deixic-code mcp Configure MCP servers\n deixic-code plugins Inspect and manage plugins\n deixic-code connections Manage API keys and delegated accounts\n deixic-code init Register a Deixic agent integration\n\nCompatibility:\n maestro remains available as an alias; MAESTRO_* variables and .maestro paths are unchanged.\n\nOptions:\n -w, --worktree Run the session in a new git worktree at ../-wt-\n on a new branch; interactive worktrees and branches are kept\n exec removes only clean worktrees with no new commits\n\nThe product runtime is native Rust; no Node.js or Bun runtime is required."; const VERSION: &str = concat!("deixic-code ", env!("CARGO_PKG_VERSION")); fn sync_command_output(command: &Command) -> Option<&'static str> { @@ -40,10 +40,20 @@ fn main() -> Result<()> { .build()? .block_on(async move { match command { - Command::Serve { port } => { + Command::Serve { + port, + parent_pid, + liveness_fd, + } => { if let Some(port) = port { std::env::set_var("PORT", port.to_string()); } + if let Some(parent_pid) = parent_pid { + std::env::set_var("MAESTRO_PARENT_PID", parent_pid.to_string()); + } + if let Some(liveness_fd) = liveness_fd { + std::env::set_var("MAESTRO_LIVENESS_FD", liveness_fd.to_string()); + } serve(RuntimeGatewayConfig::from_env()).await } Command::Forward => maestro_tui::run_cli(raw_args).await, diff --git a/packages/maestro-rs/tests/dispatch.rs b/packages/maestro-rs/tests/dispatch.rs index 2679c8d84..580578b79 100644 --- a/packages/maestro-rs/tests/dispatch.rs +++ b/packages/maestro-rs/tests/dispatch.rs @@ -26,11 +26,33 @@ struct FixtureCase { #[test] fn serve_dispatches_to_in_process_control_plane() { - assert_eq!(classify(["serve"]).unwrap(), Command::Serve { port: None }); + assert_eq!( + classify(["serve"]).unwrap(), + Command::Serve { + port: None, + parent_pid: None, + liveness_fd: None, + } + ); assert_eq!( classify(["serve", "--port", "9090"]).unwrap(), - Command::Serve { port: Some(9090) } + Command::Serve { + port: Some(9090), + parent_pid: None, + liveness_fd: None, + } + ); + assert_eq!( + classify(["serve", "--parent-pid", "42"]).unwrap(), + Command::Serve { + port: None, + parent_pid: Some(42), + liveness_fd: None, + } ); + assert!(classify(["serve", "--parent-pid", "42", "--liveness-fd", "3"]).is_err()); + assert!(classify(["serve", "--parent-pid", "0"]).is_err()); + assert!(classify(["serve", "--liveness-fd", "-1"]).is_err()); } #[test] @@ -84,7 +106,16 @@ fn frozen_cli_routes_are_owned_by_native_dispatch() { let command = result.unwrap_or_else(|error| panic!("{}: {error}", case.name)); match (case.route.as_str(), case.name.as_str()) { ("native-control-plane", _) => { - assert_eq!(command, Command::Serve { port: None }, "{}", case.name) + assert_eq!( + command, + Command::Serve { + port: None, + parent_pid: None, + liveness_fd: None, + }, + "{}", + case.name + ) } ("native", "version") => assert_eq!(command, Command::Version, "{}", case.name), ("native", "help" | "hidden-help") => { diff --git a/packages/presentation-rs/src/components/composer.rs b/packages/presentation-rs/src/components/composer.rs index 42884b246..996fe0f6d 100644 --- a/packages/presentation-rs/src/components/composer.rs +++ b/packages/presentation-rs/src/components/composer.rs @@ -5,7 +5,7 @@ use maestro_ui::{ }; use ratatui::{ buffer::Buffer, - layout::{Alignment, Rect}, + layout::Rect, style::Style, text::Line, widgets::{Block, BorderType, Borders, Paragraph, Widget}, @@ -24,7 +24,8 @@ pub struct Composer<'a> { } impl Composer<'_> { - fn inner(area: Rect) -> Rect { + fn inner(&self, area: Rect) -> Rect { + let area = self.frame_area(area); Rect::new( area.x.saturating_add(1), area.y.saturating_add(1), @@ -33,14 +34,23 @@ impl Composer<'_> { ) } + fn frame_area(&self, area: Rect) -> Rect { + Rect { + height: area + .height + .saturating_sub(u16::from(self.footer.is_some() && area.height >= 4)), + ..area + } + } + fn queue_height(&self, area: Rect) -> u16 { (self.queued.len().min(u16::MAX as usize) as u16) - .min(Self::inner(area).height.saturating_sub(1)) + .min(self.inner(area).height.saturating_sub(1)) } /// The exact viewport used by rendering and terminal cursor placement. pub fn editor_area(&self, area: Rect) -> Rect { - let inner = Self::inner(area); + let inner = self.inner(area); let queued = self.queue_height(area); Rect::new( inner.x.saturating_add(PROMPT_WIDTH), @@ -56,7 +66,7 @@ impl Composer<'_> { } let editor = self.editor_area(area); if self.editor.is_empty() { - let inner = Self::inner(area); + let inner = self.inner(area); return Some(( inner .x @@ -86,24 +96,32 @@ impl Widget for Composer<'_> { return; } let theme = self.theme.on_panel(); - let mut frame = Block::default() + let frame = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(if self.busy { theme.muted } else { theme.border })) .style(Style::default().bg(theme.surface)); - // Keep runtime context beside the place where input is committed. The - // block clips the title inside its corners, including on narrow views. - if let Some(footer) = self.footer.filter(|text| !text.is_empty()) { - frame = frame.title( - Line::styled(format!(" {footer} "), Style::default().fg(theme.muted)) - .alignment(Alignment::Right), - ); + let frame_area = self.frame_area(area); + frame.render(frame_area, buf); + if frame_area.height < area.height { + if let Some(footer) = self.footer { + Paragraph::new(footer) + .style(Style::default().fg(theme.muted)) + .render( + Rect::new( + area.x + 1, + area.bottom() - 1, + area.width.saturating_sub(2), + 1, + ), + buf, + ); + } } - frame.render(area, buf); if area.height < 3 || area.width < 3 { return; } - let inner = Self::inner(area); + let inner = self.inner(area); let queued = self.queue_height(area); if queued > 0 { let mut lines = self.queued[..usize::from(queued)].to_vec(); diff --git a/packages/presentation-rs/src/components/tool_result.rs b/packages/presentation-rs/src/components/tool_result.rs index 54545fd6c..388f22cb1 100644 --- a/packages/presentation-rs/src/components/tool_result.rs +++ b/packages/presentation-rs/src/components/tool_result.rs @@ -86,6 +86,12 @@ impl ToolResult<'_> { header.push(Span::styled(hint.to_owned(), Style::default().fg(t.muted))); } let mut lines = vec![Line::from(header)]; + // Successful routine output stays available through expansion. Failed, + // blocked and pending operations retain their actionable preview. + if !self.expanded && matches!(self.phase, ToolPhase::Completed) && self.truncation.is_none() + { + return lines; + } let row = |text: String, color| { Line::from(vec![ Span::styled(" │ ", Style::default().fg(t.border)), diff --git a/packages/presentation-rs/src/palette.rs b/packages/presentation-rs/src/palette.rs index f25e79340..241c1d0f9 100644 --- a/packages/presentation-rs/src/palette.rs +++ b/packages/presentation-rs/src/palette.rs @@ -10,8 +10,8 @@ pub fn conversation() -> UiTheme { use crate::shimmer::{DEIXIC_ACCENT, DEIXIC_BORDER, DEIXIC_MUTED, DEIXIC_SURFACE, DEIXIC_TEXT}; let color = |(r, g, b)| Color::Rgb(r, g, b); UiTheme { - panel: Some(Color::Rgb(0x21, 0x1f, 0x30)), - selection: Some(Color::Rgb(0x35, 0x2c, 0x50)), + panel: Some(Color::Rgb(0x22, 0x22, 0x26)), + selection: Some(Color::Rgb(0x32, 0x2e, 0x40)), surface: color(DEIXIC_SURFACE), text: color(DEIXIC_TEXT), muted: color(DEIXIC_MUTED), @@ -31,7 +31,7 @@ mod tests { fn command_controls_and_conversation_share_the_brand_palette() { let controls = default_controls(); assert_eq!(controls, conversation()); - assert_eq!(controls.surface, Color::Rgb(0x17, 0x16, 0x24)); + assert_eq!(controls.surface, Color::Rgb(0x19, 0x19, 0x1b)); assert_eq!(controls.focus, Color::Rgb(0x9c, 0x92, 0xfc)); } } diff --git a/packages/presentation-rs/src/shimmer.rs b/packages/presentation-rs/src/shimmer.rs index f1c9fadd6..c0827f9d5 100644 --- a/packages/presentation-rs/src/shimmer.rs +++ b/packages/presentation-rs/src/shimmer.rs @@ -39,16 +39,16 @@ pub const DEIXIC_LOGO_BASE: (u8, u8, u8) = (0x4a, 0x42, 0x9a); pub const DEIXIC_LOGO_HILITE: (u8, u8, u8) = (0xef, 0xeb, 0xff); /// Near-black surface used by the focused Maestro stage. -pub const DEIXIC_SURFACE: (u8, u8, u8) = DEIXIC_INK; +pub const DEIXIC_SURFACE: (u8, u8, u8) = (0x19, 0x19, 0x1b); -/// Quiet violet-gray border used for structural chrome. -pub const DEIXIC_BORDER: (u8, u8, u8) = (0x49, 0x43, 0x60); +/// Neutral gray border used for structural chrome. +pub const DEIXIC_BORDER: (u8, u8, u8) = (0x41, 0x40, 0x46); -/// Muted lavender for supporting labels and placeholder text. -pub const DEIXIC_MUTED: (u8, u8, u8) = (0xad, 0xa6, 0xc2); +/// Muted gray for supporting labels and placeholder text. +pub const DEIXIC_MUTED: (u8, u8, u8) = (0xa6, 0xa4, 0xad); /// Soft text color for primary interactive content. -pub const DEIXIC_TEXT: (u8, u8, u8) = (0xf2, 0xef, 0xfa); +pub const DEIXIC_TEXT: (u8, u8, u8) = (0xe9, 0xe8, 0xe5); // ───────────────────────────────────────────────────────────────────────────── // PROCESS CLOCK diff --git a/packages/presentation-rs/tests/conversation.rs b/packages/presentation-rs/tests/conversation.rs index cbcb3e671..c241815ca 100644 --- a/packages/presentation-rs/tests/conversation.rs +++ b/packages/presentation-rs/tests/conversation.rs @@ -133,7 +133,7 @@ fn empty_editor_suggestion_preserves_prompt_spacing() { } #[test] -fn composer_frames_context_without_changing_editor_geometry() { +fn composer_places_context_below_the_editor_frame() { let mut editor = TextArea::new(); editor.set_text("Review the changes"); for busy in [false, true] { @@ -146,15 +146,17 @@ fn composer_frames_context_without_changing_editor_geometry() { completion: None, theme: UiTheme::default(), }; - assert_eq!(view.editor_area(area), Rect::new(5, 2, 56, 2)); + assert_eq!(view.editor_area(area), Rect::new(5, 2, 56, 1)); let mut buf = Buffer::empty(Rect::new(0, 0, 64, 6)); view.render(area, &mut buf); assert_eq!(buf[(2, 1)].symbol(), "╭"); assert_eq!(buf[(61, 1)].symbol(), "╮"); - assert_eq!(buf[(2, 4)].symbol(), "╰"); - assert_eq!(buf[(61, 4)].symbol(), "╯"); + assert_eq!(buf[(2, 3)].symbol(), "╰"); + assert_eq!(buf[(61, 3)].symbol(), "╯"); let top: String = (2..62).map(|x| buf[(x, 1)].symbol()).collect(); - assert!(top.contains(" Mode: Plan · Claude ")); + assert!(!top.contains("Mode:")); + let footer: String = (2..62).map(|x| buf[(x, 4)].symbol()).collect(); + assert!(footer.contains("Mode: Plan · Claude")); assert!(text(&buf).contains("> Review the changes")); assert_eq!(buf[(3, 2)].fg, UiTheme::default().focus); } @@ -184,8 +186,59 @@ fn composer_context_and_unicode_stay_inside_tiny_frames() { assert_eq!(buf[(area.right(), 2)].symbol(), "x"); if width >= 2 && height >= 2 { assert_eq!(buf[(area.right() - 1, 2)].symbol(), "╮"); - assert_eq!(buf[(2, area.bottom() - 1)].symbol(), "╰"); + let frame_bottom = area.bottom() - if height >= 4 { 2 } else { 1 }; + assert_eq!(buf[(2, frame_bottom)].symbol(), "╰"); } } } } + +#[test] +fn completed_results_disclose_details_without_hiding_truncation() { + let mut view = ToolResult { + phase: ToolPhase::Completed, + summary: "Read README.md", + arguments: "README.md", + output: "first\nlast", + expanded: false, + detail: "read #read-1", + truncation: None, + theme: UiTheme::default(), + }; + assert_eq!(view.height(60), 1); + view.expanded = true; + assert!( + view.lines(60) + .iter() + .any(|line| line.to_string().contains("last")) + ); + view.expanded = false; + view.truncation = Some("Output limited by caller"); + assert!( + view.lines(60) + .iter() + .any(|line| line.to_string().contains("Output limited")) + ); +} + +#[test] +fn footer_and_wrapped_cursor_use_the_same_viewport() { + let mut editor = TextArea::new(); + editor.set_text("one two three four five six seven"); + editor.set_cursor(editor.text().len()); + let view = Composer { + editor: &editor, + queued: &[], + busy: false, + footer: Some("Mode: Act"), + completion: None, + theme: UiTheme::default(), + }; + let area = Rect::new(0, 0, 16, 4); + assert_eq!(view.editor_area(area).height, 1); + assert_eq!(view.cursor_pos(area).unwrap().1, 1); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + assert!(text(&buf).contains("seven")); + assert!(text(&buf).contains("Mode: Act")); +} diff --git a/packages/runtime-gateway-rs/Cargo.toml b/packages/runtime-gateway-rs/Cargo.toml index bb6b897a5..f3d402ff6 100644 --- a/packages/runtime-gateway-rs/Cargo.toml +++ b/packages/runtime-gateway-rs/Cargo.toml @@ -26,7 +26,7 @@ pdf-extract.workspace = true reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] } serde.workspace = true serde_json.workspace = true -sha1 = "0.10" +sha1 = "0.11" sha2 = "0.11" thiserror.workspace = true tracing.workspace = true diff --git a/packages/runtime-gateway-rs/src/lib.rs b/packages/runtime-gateway-rs/src/lib.rs index db054810f..01f19d43c 100644 --- a/packages/runtime-gateway-rs/src/lib.rs +++ b/packages/runtime-gateway-rs/src/lib.rs @@ -18,6 +18,8 @@ use std::collections::{HashMap, HashSet}; use std::env; use std::io::{Cursor, Read}; use std::net::IpAddr; +#[cfg(unix)] +use std::os::fd::FromRawFd; use std::path::{Path, PathBuf}; #[cfg(test)] use std::process; @@ -606,17 +608,130 @@ struct HookConcurrencySnapshot { pub async fn serve(config: RuntimeGatewayConfig) -> anyhow::Result<()> { config.validate_startup()?; + parse_ownership_watch( + env::var("MAESTRO_PARENT_PID").ok().as_deref(), + env::var("MAESTRO_LIVENESS_FD").ok().as_deref(), + )?; let listen_addr = config.listen_addr(); let listener = TcpListener::bind(&listen_addr).await?; println!("maestro rust server listening on http://{}", listen_addr); serve_listener(listener, config).await } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OwnershipWatch { + ParentPid(u32), + LivenessFd(i32), +} + +fn parse_ownership_watch( + parent_pid: Option<&str>, + liveness_fd: Option<&str>, +) -> anyhow::Result> { + if parent_pid.is_some() && liveness_fd.is_some() { + anyhow::bail!("MAESTRO_PARENT_PID and MAESTRO_LIVENESS_FD are mutually exclusive"); + } + if let Some(value) = parent_pid { + let pid = value + .parse::() + .context("MAESTRO_PARENT_PID must be a positive process id")?; + if pid == 0 || pid == std::process::id() { + anyhow::bail!("MAESTRO_PARENT_PID must identify another live process"); + } + return Ok(Some(OwnershipWatch::ParentPid(pid))); + } + if let Some(value) = liveness_fd { + let fd = value + .parse::() + .context("MAESTRO_LIVENESS_FD must be a non-negative file descriptor")?; + if fd < 0 { + anyhow::bail!("MAESTRO_LIVENESS_FD must be a non-negative file descriptor"); + } + return Ok(Some(OwnershipWatch::LivenessFd(fd))); + } + Ok(None) +} + +#[cfg(unix)] +fn process_is_alive(pid: u32) -> bool { + let result = unsafe { libc::kill(pid as libc::pid_t, 0) }; + if result == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(not(unix))] +fn process_is_alive(_pid: u32) -> bool { + true +} + +async fn wait_for_owner_loss(watch: OwnershipWatch) -> String { + match watch { + OwnershipWatch::ParentPid(pid) => loop { + if !process_is_alive(pid) { + return format!("parent_process_exited:{pid}"); + } + tokio::time::sleep(Duration::from_secs(1)).await; + }, + OwnershipWatch::LivenessFd(fd) => { + #[cfg(unix)] + { + tokio::task::spawn_blocking(move || { + let mut file = unsafe { std::fs::File::from_raw_fd(fd) }; + let mut byte = [0_u8; 1]; + loop { + match file.read(&mut byte) { + Ok(0) => return format!("liveness_pipe_closed:{fd}"), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) => return format!("liveness_pipe_failed:{fd}:{error}"), + } + } + }) + .await + .unwrap_or_else(|error| format!("liveness_monitor_failed:{error}")) + } + #[cfg(not(unix))] + { + format!("liveness_fd_unsupported:{fd}") + } + } + } +} + +async fn write_daemon_exit_receipt(reason: &str) -> anyhow::Result<()> { + let Some(path) = env::var_os("MAESTRO_DAEMON_EXIT_RECEIPT").map(PathBuf::from) else { + return Ok(()); + }; + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + tokio::fs::create_dir_all(parent).await?; + } + let temporary = path.with_extension(format!("tmp-{}", std::process::id())); + let payload = serde_json::to_vec_pretty(&serde_json::json!({ + "schemaVersion": 1, + "status": "stopped", + "reason": reason, + "pid": std::process::id(), + "timestamp": chrono::Utc::now().to_rfc3339(), + }))?; + tokio::fs::write(&temporary, payload).await?; + tokio::fs::rename(&temporary, &path).await?; + Ok(()) +} + pub async fn serve_listener( listener: TcpListener, config: RuntimeGatewayConfig, ) -> anyhow::Result<()> { config.validate_startup()?; + let ownership_watch = parse_ownership_watch( + env::var("MAESTRO_PARENT_PID").ok().as_deref(), + env::var("MAESTRO_LIVENESS_FD").ok().as_deref(), + )?; let _telemetry = TelemetryGuard::init(TelemetryConfig::new( "maestro-runtime-gateway", env!("CARGO_PKG_VERSION"), @@ -692,8 +807,23 @@ pub async fn serve_listener( ); maybe_spawn_a2a_platform_registration_loop(config.clone()); + let mut owner_loss = Box::pin(async move { + match ownership_watch { + Some(watch) => wait_for_owner_loss(watch).await, + None => std::future::pending::().await, + } + }); + loop { - let (stream, _) = match listener.accept().await { + let accepted = tokio::select! { + reason = &mut owner_loss => { + eprintln!("runtime-gateway owner lost: {reason}"); + write_daemon_exit_receipt(&reason).await?; + return Ok(()); + } + accepted = listener.accept() => accepted, + }; + let (stream, _) = match accepted { Ok(connection) => connection, Err(error) => { eprintln!("runtime-gateway accept failed: {error}"); diff --git a/packages/runtime-gateway-rs/src/sessions.rs b/packages/runtime-gateway-rs/src/sessions.rs index 47d8a8cf8..86f2645a3 100644 --- a/packages/runtime-gateway-rs/src/sessions.rs +++ b/packages/runtime-gateway-rs/src/sessions.rs @@ -4,6 +4,9 @@ use crate::session_messaging::{message_targets_session, persist_message_store_sn #[derive(Debug, Deserialize, Default)] pub(super) struct SessionCreateRequest { title: Option, + tags: Option>, + #[serde(rename = "logGroupId")] + log_group_id: Option, } #[derive(Debug, Deserialize, Default)] @@ -12,6 +15,15 @@ pub(super) struct SessionUpdateRequest { title: Option, favorite: Option, tags: Option>, + #[serde(default, deserialize_with = "deserialize_log_group_update")] + log_group_id: Option>, +} + +fn deserialize_log_group_update<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(Some) } #[derive(Clone, Default, Serialize, Deserialize)] @@ -40,6 +52,8 @@ pub(super) struct SessionRecord { pub(super) favorite: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(super) tags: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) log_group_id: Option, #[serde(default)] pub(super) messages: Vec, } @@ -103,6 +117,13 @@ pub(super) async fn handle_session_endpoint( } }; let mut session = create_session_record(request.title, auth.subject.clone()); + session.tags = request.tags.unwrap_or_default(); + session.log_group_id = match normalize_log_group_id(request.log_group_id) { + Ok(log_group_id) => log_group_id, + Err(error) => { + return json_response(400, &serde_json::json!({ "error": error })); + } + }; bind_session_to_auth(&mut session, &auth); let value = session_full_value(&session); { @@ -176,6 +197,18 @@ pub(super) async fn handle_session_endpoint( } } }; + let log_group_update = match request.log_group_id.as_ref() { + None => None, + Some(None) => Some(None), + Some(Some(log_group_id)) => { + match normalize_log_group_id(Some(log_group_id.clone())) { + Ok(log_group_id) => Some(log_group_id), + Err(error) => { + return json_response(400, &serde_json::json!({ "error": error })); + } + } + } + }; let mut sessions = state.sessions.lock().await; let Some(session) = sessions.sessions.get_mut(session_path.id) else { return json_response(404, &serde_json::json!({ "error": "Session not found" })); @@ -192,6 +225,9 @@ pub(super) async fn handle_session_endpoint( if let Some(tags) = request.tags { session.tags = tags; } + if let Some(log_group_id) = log_group_update { + session.log_group_id = log_group_id; + } session.updated_at = now_rfc3339(); let value = session_summary_value(session); drop(sessions); @@ -484,10 +520,27 @@ pub(super) fn create_session_record(title: Option, owner: Option message_count: 0, favorite: None, tags: Vec::new(), + log_group_id: None, messages: Vec::new(), } } +fn normalize_log_group_id(value: Option) -> Result, &'static str> { + let Some(value) = value else { + return Ok(None); + }; + let value = value.trim().to_owned(); + if value.is_empty() + || value.len() > 128 + || !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "-_.:/".contains(character)) + { + return Err("logGroupId must be 1-128 ASCII letters, digits, or -_.:/"); + } + Ok(Some(value)) +} + pub(super) fn normalize_title(title: Option) -> Option { title .map(|value| value.trim().to_string()) @@ -1824,6 +1877,9 @@ pub(super) fn session_summary_value(session: &SessionRecord) -> Value { if !session.tags.is_empty() { value["tags"] = serde_json::json!(session.tags); } + if let Some(log_group_id) = &session.log_group_id { + value["logGroupId"] = Value::String(log_group_id.clone()); + } value } @@ -1951,3 +2007,33 @@ pub(super) fn pending_tool_response_from_payload(payload: &Value) -> (bool, Opti (true, Some(ToolResult::success(output))) } } + +#[cfg(test)] +mod lineage_tests { + use super::*; + + #[test] + fn log_group_ids_accept_stable_lineage_and_reject_unsafe_values() { + let create: SessionCreateRequest = + serde_json::from_str(r#"{"tags":["reviewed"],"logGroupId":"release:42"}"#).unwrap(); + assert_eq!(create.tags, Some(vec!["reviewed".to_string()])); + assert_eq!(create.log_group_id.as_deref(), Some("release:42")); + + assert_eq!( + normalize_log_group_id(Some(" deploy:release/42 ".to_string())) + .unwrap() + .as_deref(), + Some("deploy:release/42") + ); + assert!(normalize_log_group_id(Some("bad group".to_string())).is_err()); + assert!(normalize_log_group_id(Some("x".repeat(129))).is_err()); + + let absent: SessionUpdateRequest = serde_json::from_str("{}").unwrap(); + assert_eq!(absent.log_group_id, None); + let clear: SessionUpdateRequest = serde_json::from_str(r#"{"logGroupId":null}"#).unwrap(); + assert_eq!(clear.log_group_id, Some(None)); + let set: SessionUpdateRequest = + serde_json::from_str(r#"{"logGroupId":"release:42"}"#).unwrap(); + assert_eq!(set.log_group_id, Some(Some("release:42".to_string()))); + } +} diff --git a/packages/runtime-gateway-rs/src/tests.rs b/packages/runtime-gateway-rs/src/tests.rs index fe18a99fc..8273c5dd4 100644 --- a/packages/runtime-gateway-rs/src/tests.rs +++ b/packages/runtime-gateway-rs/src/tests.rs @@ -147,6 +147,37 @@ fn clear_env(names: &[&str]) { } } +#[test] +fn daemon_ownership_requires_one_valid_external_owner() { + assert_eq!(parse_ownership_watch(None, None).unwrap(), None); + assert_eq!( + parse_ownership_watch(Some("42"), None).unwrap(), + Some(OwnershipWatch::ParentPid(42)) + ); + assert_eq!( + parse_ownership_watch(None, Some("7")).unwrap(), + Some(OwnershipWatch::LivenessFd(7)) + ); + assert!(parse_ownership_watch(Some("42"), Some("7")).is_err()); + assert!(parse_ownership_watch(Some("0"), None).is_err()); + assert!(parse_ownership_watch(None, Some("-1")).is_err()); +} + +#[cfg(unix)] +#[tokio::test] +async fn daemon_liveness_pipe_detects_supervisor_disconnect() { + let mut fds = [0_i32; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + assert_eq!(unsafe { libc::close(fds[1]) }, 0); + let reason = tokio::time::timeout( + Duration::from_secs(1), + wait_for_owner_loss(OwnershipWatch::LivenessFd(fds[0])), + ) + .await + .expect("liveness monitor should observe EOF"); + assert_eq!(reason, format!("liveness_pipe_closed:{}", fds[0])); +} + #[test] fn control_plane_defaults_to_loopback_bind() { let _guard = ENV_LOCK.blocking_lock(); @@ -2665,6 +2696,7 @@ fn test_session_record(id: &str) -> SessionRecord { message_count: 0, favorite: None, tags: Vec::new(), + log_group_id: None, messages: Vec::new(), } } @@ -4782,6 +4814,8 @@ fn capsule_deadline_cannot_leave_an_ambient_validator_process_running() { #[tokio::test(flavor = "current_thread")] async fn a2a_subagent_request_rejects_invalid_task_capsule_before_claim() { + // Authentication reads the hosted-profile environment changed by sibling tests. + let _guard = ENV_LOCK.lock().await; for return_immediately in [false, true] { let mut subagent_request = valid_code_writer_capsule(); subagent_request["capsule"]["mutationBoundary"]["paths"] = serde_json::json!(["../deploy"]); @@ -4815,7 +4849,7 @@ async fn a2a_subagent_request_rejects_invalid_task_capsule_before_claim() { let response = response_json(handle_a2a_endpoint(&mut server, &mut initial, head, &state).await); - assert_eq!(response["error"]["code"], "INVALID_REQUEST"); + assert_eq!(response["error"]["code"], "INVALID_REQUEST", "{response}"); assert_eq!( state.a2a_tasks.lock().await.len(), 0, @@ -13178,6 +13212,7 @@ async fn delete_session_subpath_returns_404_without_removing_session() { message_count: 0, favorite: None, tags: Vec::new(), + log_group_id: None, messages: Vec::new(), }; let state = AppState { diff --git a/packages/runtime-rs/BUILD.bazel b/packages/runtime-rs/BUILD.bazel index e9e295d1a..016d3ed44 100644 --- a/packages/runtime-rs/BUILD.bazel +++ b/packages/runtime-rs/BUILD.bazel @@ -16,7 +16,7 @@ rust_library( "@crates//:anyhow-1.0.103", "@crates//:base64-0.22.1", "@crates//:chrono-0.4.45", - "@crates//:dirs-6.0.0", + "@crates//:dirs-7.0.0", "@crates//:futures-0.3.32", "//products/maestro/packages/ai-rs:maestro_ai", "//products/maestro/packages/codex-rs:maestro_codex", @@ -24,7 +24,7 @@ rust_library( "//products/maestro/packages/context-rs:maestro_context", "//products/maestro/packages/runtime-contracts-rs:maestro_runtime_contracts", "//products/maestro/packages/sandbox-rs:maestro_sandbox", - "@crates//:rand-0.9.4", + "@crates//:rand-0.10.1", "@crates//:regex-1.13.0", "@crates//:serde-1.0.228", "@crates//:serde_json-1.0.150", diff --git a/packages/runtime-rs/src/agent/credential_store.rs b/packages/runtime-rs/src/agent/credential_store.rs index 3cd9f9bcd..02a2d7b40 100644 --- a/packages/runtime-rs/src/agent/credential_store.rs +++ b/packages/runtime-rs/src/agent/credential_store.rs @@ -52,7 +52,7 @@ //! - References are opaque and don't reveal credential content //! - Store is cleared when dropped -use rand::Rng; +use rand::RngExt; use regex::{Captures, Regex}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; diff --git a/packages/runtime-rs/src/agent/native.rs b/packages/runtime-rs/src/agent/native.rs index 06da98fb3..261fd35aa 100644 --- a/packages/runtime-rs/src/agent/native.rs +++ b/packages/runtime-rs/src/agent/native.rs @@ -975,6 +975,19 @@ enum AgentCommand { path: String, }, + /// Enable or disable the live hook system owned by the execution host. + SetHooksEnabled { + enabled: bool, + }, + + /// Reload hook configuration in the live execution host. + ReloadHooks, + + /// Read live hook counts and metrics from the execution host. + InspectHooks { + reply: oneshot::Sender, + }, + /// Update whether the goal lifecycle tools are exposed to the model. SetGoalToolsVisible { visible: bool, @@ -1953,6 +1966,33 @@ impl NativeAgent { Ok(()) } + /// Enable or disable hooks in the live native execution host. + pub fn set_hooks_enabled(&self, enabled: bool) -> Result<()> { + self.command_tx + .send(AgentCommand::SetHooksEnabled { enabled }) + .map_err(|e| anyhow::anyhow!("Failed to update hook state: {e}"))?; + Ok(()) + } + + /// Reload hooks from the execution host's authoritative configuration. + pub fn reload_hooks(&self) -> Result<()> { + self.command_tx + .send(AgentCommand::ReloadHooks) + .map_err(|e| anyhow::anyhow!("Failed to reload hooks: {e}"))?; + Ok(()) + } + + /// Return the live hook state owned by the native execution host. + pub async fn inspect_hooks(&self) -> Result { + let (reply, response) = oneshot::channel(); + self.command_tx + .send(AgentCommand::InspectHooks { reply }) + .map_err(|e| anyhow::anyhow!("Failed to inspect hooks: {e}"))?; + response + .await + .map_err(|e| anyhow::anyhow!("Hook inspection was interrupted: {e}")) + } + /// Set whether the goal lifecycle tools are exposed to the model. pub fn set_goal_tools_visible(&self, visible: bool) { let _ = self diff --git a/packages/runtime-rs/src/agent/native/commands.rs b/packages/runtime-rs/src/agent/native/commands.rs index 6fe1fb256..d97ee095e 100644 --- a/packages/runtime-rs/src/agent/native/commands.rs +++ b/packages/runtime-rs/src/agent/native/commands.rs @@ -293,6 +293,22 @@ impl NativeAgentRunner { AgentCommand::SetHookLogFile { path } => { self.hooks.hook_set_log_file(Some(path)).await; } + AgentCommand::SetHooksEnabled { enabled } => { + self.hooks.hook_set_enabled(enabled).await; + } + AgentCommand::ReloadHooks => { + if let Err(error) = self.hooks.hook_reload().await { + let _ = self.event_tx.send(FromAgent::Error { + message: format!("Failed to reload hooks: {error}"), + fatal: false, + terminal: false, + retryable: false, + }); + } + } + AgentCommand::InspectHooks { reply } => { + let _ = reply.send(self.hooks.hook_runtime_snapshot().await); + } AgentCommand::SetGoalToolsVisible { visible } => { self.set_goal_tools_visible(visible); } @@ -1308,6 +1324,22 @@ impl NativeAgentRunner { AgentCommand::SetHookLogFile { path } => { self.hooks.hook_set_log_file(Some(path)).await; } + AgentCommand::SetHooksEnabled { enabled } => { + self.hooks.hook_set_enabled(enabled).await; + } + AgentCommand::ReloadHooks => { + if let Err(error) = self.hooks.hook_reload().await { + let _ = self.event_tx.send(FromAgent::Error { + message: format!("Failed to reload hooks: {error}"), + fatal: false, + terminal: false, + retryable: false, + }); + } + } + AgentCommand::InspectHooks { reply } => { + let _ = reply.send(self.hooks.hook_runtime_snapshot().await); + } AgentCommand::SetGoalToolsVisible { visible } => { self.set_goal_tools_visible(visible); } diff --git a/packages/runtime-rs/src/agent/native_host.rs b/packages/runtime-rs/src/agent/native_host.rs index 93421bed4..13d7d4b97 100644 --- a/packages/runtime-rs/src/agent/native_host.rs +++ b/packages/runtime-rs/src/agent/native_host.rs @@ -51,6 +51,20 @@ pub enum QueueMode { /// Boxed future used by asynchronous operations in the host seam. pub type NativeHostFuture<'a, T> = Pin + Send + 'a>>; +/// Live hook state exposed without leaking hook implementations or secrets. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct NativeHookRuntimeSnapshot { + pub native_hooks: usize, + pub lua_scripts: usize, + pub wasm_plugins: usize, + pub enabled: bool, + pub pre_tool_use_count: u64, + pub post_tool_use_count: u64, + pub overflow_count: u64, + pub blocks: u64, + pub total_duration_ms: u64, +} + /// Catalog-reported model support, supplied by the composing host without /// coupling the runtime to a catalog implementation. None means unknown, not /// unsupported. These facts do not grant tool access or model-change authority. @@ -420,6 +434,15 @@ pub trait NativeExecutionHost: Send + Sync { fn hook_increment_turn<'a>(&'a self) -> NativeHostFuture<'a, ()>; fn hook_set_model<'a>(&'a self, model: &'a str) -> NativeHostFuture<'a, ()>; fn hook_set_log_file<'a>(&'a self, path: Option) -> NativeHostFuture<'a, ()>; + fn hook_set_enabled<'a>(&'a self, _enabled: bool) -> NativeHostFuture<'a, ()> { + Box::pin(async {}) + } + fn hook_reload<'a>(&'a self) -> NativeHostFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + fn hook_runtime_snapshot<'a>(&'a self) -> NativeHostFuture<'a, NativeHookRuntimeSnapshot> { + Box::pin(async { NativeHookRuntimeSnapshot::default() }) + } fn render_hook_context(&self, event: NativeHookEvent, context: &str) -> Result; // Host-resolved support callbacks. These keep auth/config/catalog and @@ -869,6 +892,18 @@ impl NativeExecutionHostHandle { self.0.hook_set_log_file(path) } + pub fn hook_set_enabled<'a>(&'a self, enabled: bool) -> NativeHostFuture<'a, ()> { + self.0.hook_set_enabled(enabled) + } + + pub fn hook_reload<'a>(&'a self) -> NativeHostFuture<'a, Result<(), String>> { + self.0.hook_reload() + } + + pub fn hook_runtime_snapshot<'a>(&'a self) -> NativeHostFuture<'a, NativeHookRuntimeSnapshot> { + self.0.hook_runtime_snapshot() + } + pub fn render_hook_context( &self, event: NativeHookEvent, diff --git a/packages/runtime-rs/src/agent/retry.rs b/packages/runtime-rs/src/agent/retry.rs index e8e049530..583efb130 100644 --- a/packages/runtime-rs/src/agent/retry.rs +++ b/packages/runtime-rs/src/agent/retry.rs @@ -40,7 +40,7 @@ use std::time::Duration; -use rand::Rng as _; +use rand::RngExt as _; /// Configuration for retry behavior #[derive(Debug, Clone)] diff --git a/packages/sandbox-rs/BUILD.bazel b/packages/sandbox-rs/BUILD.bazel index 23eb9a3a4..25fc2ec99 100644 --- a/packages/sandbox-rs/BUILD.bazel +++ b/packages/sandbox-rs/BUILD.bazel @@ -13,10 +13,10 @@ cargo_toml_env_vars( rust_library( name = "maestro_sandbox", deps = [ - "@crates//:dirs-6.0.0", + "@crates//:dirs-7.0.0", "@crates//:dunce-1.0.5", "@crates//:libc-0.2.186", - "@crates//:rand-0.9.4", + "@crates//:rand-0.10.1", "@crates//:regex-1.13.0", "@crates//:serde-1.0.228", "@crates//:serde_json-1.0.150", diff --git a/packages/sandbox-rs/src/lib.rs b/packages/sandbox-rs/src/lib.rs index 8f91c8ac4..6c4265f55 100644 --- a/packages/sandbox-rs/src/lib.rs +++ b/packages/sandbox-rs/src/lib.rs @@ -367,7 +367,7 @@ const SANDBOX_CACHE_PROCESS_MARKER: &str = ".maestro-process"; pub fn sandbox_cache_session_id() -> &'static str { static SESSION_ID: std::sync::OnceLock = std::sync::OnceLock::new(); SESSION_ID.get_or_init(|| { - use rand::Rng; + use rand::RngExt; use std::fmt::Write as _; let bytes: [u8; 16] = rand::rng().random(); bytes diff --git a/packages/tui-rs/Cargo.toml b/packages/tui-rs/Cargo.toml index 3cddad88c..cd82356ee 100644 --- a/packages/tui-rs/Cargo.toml +++ b/packages/tui-rs/Cargo.toml @@ -74,7 +74,7 @@ wait-timeout.workspace = true # Serialization for IPC ring.workspace = true -ed25519-dalek = { version = "2", default-features = false } +ed25519-dalek = { version = "3", default-features = false } serde.workspace = true serde_json.workspace = true prost = "0.14" @@ -102,7 +102,7 @@ regex.workspace = true dirs.workspace = true dunce.workspace = true fd-lock.workspace = true -toml = "0.8" +toml = "0.9" libc.workspace = true zip.workspace = true tempfile.workspace = true @@ -129,7 +129,7 @@ rand.workspace = true url = "2" # Shell escaping/unescaping -shlex = "1" +shlex = "2" # Base64 encoding for images base64.workspace = true @@ -152,7 +152,7 @@ arboard = { version = "3", optional = true } # Lua scripting for hooks (optional) # Uses vendored Lua to avoid system dependency # "send" feature makes Lua thread-safe (uses Arc instead of Rc) -mlua = { version = "0.10", features = ["lua54", "serialize", "vendored", "send"], optional = true } +mlua = { version = "0.12", features = ["lua54", "serialize", "vendored", "send"], optional = true } # WASM runtime for hook plugins (optional) wasmtime = { version = "47", optional = true } @@ -192,7 +192,7 @@ maestro-session = { workspace = true, features = ["test-support"] } # Reconstruct real terminal cells in PTY assertions, including cursor-only spaces. vt100 = "0.16.2" pretty_assertions = "1" -criterion = { version = "0.5", features = ["html_reports"] } +criterion = { version = "0.8", features = ["html_reports"] } wat = "1" # Enables `#[tokio::test(start_paused = true)]` for deterministic timeout tests. tokio = { workspace = true, features = ["test-util"] } diff --git a/packages/tui-rs/benches/batch_bench.rs b/packages/tui-rs/benches/batch_bench.rs index 52cf1a00d..1c46fc26a 100644 --- a/packages/tui-rs/benches/batch_bench.rs +++ b/packages/tui-rs/benches/batch_bench.rs @@ -2,9 +2,10 @@ //! //! Run with: cargo bench --bench batch_bench -use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; use maestro_tui::tools::{BatchConfig, BatchExecutor, BatchToolCall}; use serde_json::json; +use std::hint::black_box; /// Benchmark batch executor creation fn bench_batch_executor_creation(c: &mut Criterion) { diff --git a/packages/tui-rs/benches/hooks_bench.rs b/packages/tui-rs/benches/hooks_bench.rs index 1e8f2d9fb..ede51c82b 100644 --- a/packages/tui-rs/benches/hooks_bench.rs +++ b/packages/tui-rs/benches/hooks_bench.rs @@ -2,10 +2,11 @@ //! //! Run with: cargo bench --all-features -use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use criterion::{Criterion, criterion_group, criterion_main}; use maestro_tui::hooks::{ HookRegistry, HookResult, IntegratedHookSystem, PreToolUseHook, PreToolUseInput, SafetyHook, }; +use std::hint::black_box; use std::sync::Arc; /// Benchmark hook registry creation diff --git a/packages/tui-rs/benches/indexer_bench.rs b/packages/tui-rs/benches/indexer_bench.rs index 508ef1f86..8345ef67a 100644 --- a/packages/tui-rs/benches/indexer_bench.rs +++ b/packages/tui-rs/benches/indexer_bench.rs @@ -2,9 +2,10 @@ //! //! Run with: cargo bench --bench indexer_bench -use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; use maestro_tui::files::{FileIndexer, IndexerConfig}; use std::fs::File; +use std::hint::black_box; use tempfile::TempDir; /// Create a test directory structure for benchmarking diff --git a/packages/tui-rs/benches/mcp_bench.rs b/packages/tui-rs/benches/mcp_bench.rs index 39e81e43b..dc9ba14a7 100644 --- a/packages/tui-rs/benches/mcp_bench.rs +++ b/packages/tui-rs/benches/mcp_bench.rs @@ -2,13 +2,14 @@ //! //! Run with: cargo bench --bench mcp_bench -use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use criterion::{Criterion, criterion_group, criterion_main}; use maestro_tui::mcp::{ McpClient, McpConfig, McpConfigScope, McpContent, McpRequest, McpResponse, McpServerConfig, McpTool, McpToolResult, McpTransport, load_mcp_config, }; use serde_json::json; use std::collections::HashMap; +use std::hint::black_box; /// Benchmark MCP client creation fn bench_mcp_client_creation(c: &mut Criterion) { diff --git a/packages/tui-rs/benches/message_layout_bench.rs b/packages/tui-rs/benches/message_layout_bench.rs index 7ec3f0b2c..733287e3d 100644 --- a/packages/tui-rs/benches/message_layout_bench.rs +++ b/packages/tui-rs/benches/message_layout_bench.rs @@ -1,9 +1,10 @@ use std::time::SystemTime; -use criterion::{BatchSize, Criterion, black_box, criterion_group, criterion_main}; +use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; use maestro_tui::components::ChatView; use maestro_tui::state::{AppState, Message, MessageKind, MessageRole}; use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; +use std::hint::black_box; const MESSAGE_COUNT: usize = 1_000; const AREA: Rect = Rect::new(0, 0, 100, 40); diff --git a/packages/tui-rs/benches/search_bench.rs b/packages/tui-rs/benches/search_bench.rs index b862dd007..6100fb1a7 100644 --- a/packages/tui-rs/benches/search_bench.rs +++ b/packages/tui-rs/benches/search_bench.rs @@ -1,7 +1,8 @@ use std::path::PathBuf; -use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; use maestro_tui::files::{FileSearch, WorkspaceFile}; +use std::hint::black_box; fn workspace_files(count: usize) -> Vec { const NAMES: [&str; 8] = [ diff --git a/packages/tui-rs/src/app/command_handlers.rs b/packages/tui-rs/src/app/command_handlers.rs index 1c12ca4e6..7bdc2030e 100644 --- a/packages/tui-rs/src/app/command_handlers.rs +++ b/packages/tui-rs/src/app/command_handlers.rs @@ -510,7 +510,7 @@ impl App { self.handle_a2a_action(action); } CommandAction::HooksManage(hooks_action) => { - self.handle_hooks_action(hooks_action); + self.handle_hooks_action(hooks_action).await; } CommandAction::ShowUsage(usage_action) => { self.handle_usage_action(usage_action); @@ -2580,74 +2580,96 @@ impl App { } /// Handle hooks management actions - pub(super) fn handle_hooks_action(&mut self, action: crate::commands::HooksAction) { + pub(super) async fn handle_hooks_action(&mut self, action: crate::commands::HooksAction) { use crate::commands::HooksAction; - // For now, display messages since hooks aren't wired into App yet - // In a full implementation, we'd access self.hooks: IntegratedHookSystem + let cwd = self.state.cwd.as_deref().unwrap_or("."); + let snapshot = || crate::cli_commands::effective_hooks_snapshot(std::path::Path::new(cwd)); + let set_enabled = |enabled| { + self.native_agent + .as_ref() + .ok_or_else(|| anyhow::anyhow!("The native agent is not running"))? + .set_hooks_enabled(enabled) + }; match action { - HooksAction::List => { - let mut msg = String::new(); - msg.push_str(self.state.locale.translate("## Hook System\n\n")); - msg.push_str("| Type | Count | Status |\n"); - msg.push_str("|------|-------|--------|\n"); - msg.push_str("| Native | 1 | SafetyHook |\n"); - msg.push_str("| Lua | 0 | - |\n"); - msg.push_str("| WASM | 0 | - |\n"); - msg.push_str("| TypeScript | 0 | - |\n\n"); - msg.push_str(self.state.locale.translate( - "*Configure hooks in `~/.composer/hooks.toml` or `.composer/hooks.toml`*\n", - )); - self.state.add_system_message(msg); - } + HooksAction::List => match snapshot() { + Ok(snapshot) => { + let mut message = crate::cli_commands::format_effective_hooks(&snapshot); + if let Some(agent) = &self.native_agent { + match agent.inspect_hooks().await { + Ok(runtime) => message.push_str(&format!( + "Runtime: {} (native={}, lua={}, wasm={})\n", + if runtime.enabled { "enabled" } else { "disabled" }, + runtime.native_hooks, + runtime.lua_scripts, + runtime.wasm_plugins, + )), + Err(error) => { + message.push_str(&format!("Runtime: unavailable ({error})\n")); + } + } + } + self.state.add_system_message(message); + } + Err(error) => self.state.error = Some(format!("Failed to load hooks: {error}")), + }, + HooksAction::Metrics => match &self.native_agent { + Some(agent) => match agent.inspect_hooks().await { + Ok(runtime) => self.state.add_system_message(self.state.locale.format( + "Hook metrics\nPreToolUse: {0}\nPostToolUse: {1}\nOverflow: {2}\nBlocks: {3}\nTotal duration: {4}ms\n", + &[ + runtime.pre_tool_use_count.to_string(), + runtime.post_tool_use_count.to_string(), + runtime.overflow_count.to_string(), + runtime.blocks.to_string(), + runtime.total_duration_ms.to_string(), + ], + )), + Err(error) => self.state.error = Some(error.to_string()), + }, + None => self.state.error = Some("The native agent is not running".to_string()), + }, HooksAction::Toggle => { - self.state.status = Some(self.state.locale.translate("Hooks toggled").to_string()); - self.state.add_system_message( - self.state - .locale - .translate("Hooks have been toggled. Use `/hooks` to see current status.") - .to_string(), - ); + let enabled = match &self.native_agent { + Some(agent) => agent.inspect_hooks().await.map(|runtime| runtime.enabled), + None => Err(anyhow::anyhow!("The native agent is not running")), + }; + let enabled = match enabled { + Ok(enabled) => enabled, + Err(error) => { + self.state.error = Some(error.to_string()); + return; + } + }; + match set_enabled(!enabled) { + Ok(()) => { + self.state.status = Some(format!( + "Hooks {}", + if enabled { "disabled" } else { "enabled" } + )); + } + Err(error) => self.state.error = Some(error.to_string()), + } } HooksAction::Reload => { - self.state.status = Some(self.state.locale.translate("Hooks reloaded").to_string()); - self.state.add_system_message( - self.state - .locale - .translate("Hook configuration reloaded from disk.") - .to_string(), - ); - } - HooksAction::Metrics => { - let mut msg = String::new(); - msg.push_str(self.state.locale.translate("## Hook Metrics\n\n")); - msg.push_str("| Metric | Value |\n"); - msg.push_str("|--------|-------|\n"); - msg.push_str(self.state.locale.translate("| PreToolUse calls | 0 |\n")); - msg.push_str(self.state.locale.translate("| PostToolUse calls | 0 |\n")); - msg.push_str("| Blocks | 0 |\n"); - msg.push_str(self.state.locale.translate("| Total duration | 0ms |\n")); - msg.push_str(self.state.locale.translate("| Avg duration | 0ms |\n")); - self.state.add_system_message(msg); - } - HooksAction::Enable => { - self.state.status = Some(self.state.locale.translate("Hooks enabled").to_string()); - self.state.add_system_message( - self.state - .locale - .translate("Hook system enabled.") - .to_string(), - ); - } - HooksAction::Disable => { - self.state.status = Some(self.state.locale.translate("Hooks disabled").to_string()); - self.state.add_system_message( - self.state - .locale - .translate("Hook system disabled.") - .to_string(), - ); + match self + .native_agent + .as_ref() + .ok_or_else(|| anyhow::anyhow!("The native agent is not running")) + .and_then(|agent| agent.reload_hooks()) + { + Ok(()) => self.state.status = Some("Hook reload queued".to_string()), + Err(error) => self.state.error = Some(error.to_string()), + } } + HooksAction::Enable => match set_enabled(true) { + Ok(()) => self.state.status = Some("Hooks enabled".to_string()), + Err(error) => self.state.error = Some(error.to_string()), + }, + HooksAction::Disable => match set_enabled(false) { + Ok(()) => self.state.status = Some("Hooks disabled".to_string()), + Err(error) => self.state.error = Some(error.to_string()), + }, } } diff --git a/packages/tui-rs/src/cli_commands.rs b/packages/tui-rs/src/cli_commands.rs index d30994659..8cc040155 100644 --- a/packages/tui-rs/src/cli_commands.rs +++ b/packages/tui-rs/src/cli_commands.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use std::fs; use std::io::{self, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; @@ -1505,6 +1505,100 @@ fn run_hooks_import(args: &[String]) -> Result { Ok(0) } +pub(crate) fn effective_hooks_snapshot(cwd: &Path) -> Result { + let loaded = crate::hooks::load_hook_config(cwd)?; + let disabled_by_environment = + std::env::var("MAESTRO_HOOKS_DISABLED").ok().as_deref() == Some("1"); + let hooks = loaded + .hooks + .iter() + .map(|hook| { + let implementation = match &hook.source { + crate::hooks::HookSource::Command(_) => "command", + crate::hooks::HookSource::Http(_) => "http", + crate::hooks::HookSource::Prompt(_) => "prompt", + crate::hooks::HookSource::LuaInline(_) => "lua_inline", + crate::hooks::HookSource::LuaFile(_) => "lua_file", + crate::hooks::HookSource::Wasm(_) => "wasm", + }; + serde_json::json!({ + "event": format!("{:?}", hook.definition.event), + "tools": hook.definition.tools, + "implementation": implementation, + "enabled": hook.definition.enabled && loaded.settings.enabled && !disabled_by_environment, + "required": hook.definition.required, + "timeoutMs": hook.definition.timeout_ms.unwrap_or(loaded.settings.timeout_ms), + "description": hook.definition.description, + }) + }) + .collect::>(); + let count = hooks.len(); + Ok(serde_json::json!({ + "schemaVersion": 1, + "enabled": loaded.settings.enabled && !disabled_by_environment, + "disabledByEnvironment": disabled_by_environment, + "sourcePaths": loaded.source_paths, + "skippedUntrustedPaths": loaded.skipped_untrusted_paths, + "hooks": hooks, + "count": count, + })) +} + +pub(crate) fn format_effective_hooks(snapshot: &serde_json::Value) -> String { + let mut output = String::new(); + output.push_str("Effective hooks\n"); + output.push_str(&format!( + "State: {}\n", + if snapshot["enabled"].as_bool().unwrap_or(false) { + "enabled" + } else { + "disabled" + } + )); + let sources = snapshot["sourcePaths"].as_array().map_or(0, Vec::len); + output.push_str(&format!("Sources: {sources}\n")); + let hooks = snapshot["hooks"].as_array().cloned().unwrap_or_default(); + if hooks.is_empty() { + output.push_str("Hooks: none\n"); + } else { + output.push_str("Hooks:\n"); + for hook in hooks { + let tools = hook["tools"] + .as_array() + .map(|tools| { + tools + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>() + .join(",") + }) + .filter(|tools| !tools.is_empty()) + .unwrap_or_else(|| "*".to_owned()); + output.push_str(&format!( + " {:<20} {:<12} tools={} {}\n", + hook["event"].as_str().unwrap_or("unknown"), + hook["implementation"].as_str().unwrap_or("unknown"), + tools, + if hook["enabled"].as_bool().unwrap_or(false) { + "enabled" + } else { + "disabled" + } + )); + } + } + if let Some(skipped) = snapshot["skippedUntrustedPaths"] + .as_array() + .filter(|paths| !paths.is_empty()) + { + output.push_str(&format!( + "Skipped untrusted hook sources: {}\n", + skipped.len() + )); + } + output +} + fn run_hooks(args: &[String]) -> Result { let sub = args.first().map(String::as_str).unwrap_or("status"); if sub == "import" { @@ -1525,45 +1619,14 @@ fn run_hooks(args: &[String]) -> Result { return Ok(1); } - println!( - "{}", - crate::localization::cli_locale().format("Hook status (native summary)", &[]) - ); - println!( - "{}", - crate::localization::cli_locale().format( - " Runtime: native TUI hooks (Lua/WASM/native + optional Node bridge)", - &[] - ) - ); - println!("{}", crate::localization::cli_locale().format(" Config: ~/.maestro/hooks.toml and project hooks (see packages/tui-rs hooks docs)", &[])); - if std::env::var("MAESTRO_HOOKS_DISABLED").ok().as_deref() == Some("1") { - println!( - "{}", - crate::localization::cli_locale() - .format(" State: disabled (MAESTRO_HOOKS_DISABLED=1)", &[]) - ); + let json = args.iter().any(|arg| arg == "--json"); + let cwd = std::env::current_dir()?; + let snapshot = effective_hooks_snapshot(&cwd)?; + if json { + println!("{}", serde_json::to_string_pretty(&snapshot)?); } else { - println!( - "{}", - crate::localization::cli_locale().format(" State: enabled (default)", &[]) - ); + print!("{}", format_effective_hooks(&snapshot)); } - println!(); - println!( - "{}", - crate::localization::cli_locale().format( - "Inspect hooks from the interactive TUI (/hooks) for live concurrency stats.", - &[] - ) - ); - println!( - "{}", - crate::localization::cli_locale().format( - "Import a Claude Code config with `deixic-code hooks import --from-claude-code`.", - &[] - ) - ); Ok(0) } diff --git a/packages/tui-rs/src/components/approval.rs b/packages/tui-rs/src/components/approval.rs index 1e8064be6..0868c04dd 100644 --- a/packages/tui-rs/src/components/approval.rs +++ b/packages/tui-rs/src/components/approval.rs @@ -62,7 +62,7 @@ //! 4. Using vertical layout to split content into sections: //! - Reason (if provided) //! - Tool name with shell indicator -//! - Command display (bordered, scrollable) +//! - Command display (inset, scrollable) //! - Queue status //! - Keyboard hints //! @@ -977,10 +977,11 @@ impl Widget for ApprovalModal<'_> { }) + self.request.command_source.as_deref().map_or(0, |source| { estimate_wrapped_rows("Source: ", source, modal_width.saturating_sub(4)) }); + let minimum_reason_rows = if source_rows == 0 { 0 } else { 2 }; // Outer border (2) + reason/source + tool (2) + command + queue (1) // + hints (2). let wanted_height = 7_u16 - .saturating_add(source_rows.max(2)) + .saturating_add(source_rows.max(minimum_reason_rows)) .saturating_add(command_rows); let modal_height = wanted_height.max(10).min(area.height); @@ -1042,18 +1043,17 @@ impl Widget for ApprovalModal<'_> { // clipping its dangerous suffix. On terminals too short for every // computed row, preserve as much command space as possible after // the source minimum and fixed sections. - const MIN_REASON_ROWS: u16 = 2; const FIXED_SECTION_ROWS: u16 = 2 + 1 + 2; // Tool + queue + hints. let max_command_rows = inner .height - .saturating_sub(FIXED_SECTION_ROWS + MIN_REASON_ROWS) + .saturating_sub(FIXED_SECTION_ROWS + minimum_reason_rows) .max(4); let command_section_rows = command_rows.min(max_command_rows); let max_reason_rows = inner .height .saturating_sub(FIXED_SECTION_ROWS + command_section_rows) - .max(MIN_REASON_ROWS); - let reason_section_rows = reason_section_rows.clamp(2, max_reason_rows); + .max(minimum_reason_rows); + let reason_section_rows = reason_section_rows.clamp(minimum_reason_rows, max_reason_rows); // Layout the content let chunks = Layout::default() @@ -1108,7 +1108,7 @@ impl Widget for ApprovalModal<'_> { .collect(); let command_block = Block::default() - .borders(Borders::ALL) + .borders(Borders::LEFT) .border_style(Style::default().fg(palette.muted)) .title(maestro_ui::localization::tr(" Command ")); @@ -1147,7 +1147,7 @@ impl Widget for ApprovalModal<'_> { KeyHint::new("w", maestro_ui::localization::tr("always")), ]); } - let hints = key_hints(&bindings, palette); + let hints = key_hints(&bindings, palette.on_panel()); Paragraph::new(hints) .wrap(Wrap { trim: true }) .alignment(Alignment::Center) diff --git a/packages/tui-rs/src/components/message.rs b/packages/tui-rs/src/components/message.rs index a20eac659..8787a8c78 100644 --- a/packages/tui-rs/src/components/message.rs +++ b/packages/tui-rs/src/components/message.rs @@ -27,7 +27,7 @@ //! - Code blocks: triple-backtick fenced blocks with syntax highlighting hints //! - Links: `[text](url)` rendered with underline styling //! -//! Markdown parsing is implemented in `parse_markdown_lines()` and `parse_markdown_line()`. +//! Markdown parsing uses the shared block renderer for both measurement and painting. //! //! ## Scrolling and Viewport Management //! @@ -51,8 +51,7 @@ //! //! Assistant messages may include "thinking" content (Claude's internal reasoning): //! - Rendered in a collapsible section with gutter (│) -//! - Shows character count badge -//! - Collapsed: shows first 2 lines as preview +//! - Collapsed: shows only the disclosure header //! - Expanded: shows full thinking content with italic dim styling //! - Toggle indicator: `[+]` / `[-]` //! @@ -186,167 +185,9 @@ fn brand_text() -> Color { themed_chrome("text", DEIXIC_TEXT) } -/// Parse markdown text into styled lines -/// Supports: **bold**, `code`, ```code blocks```, [links](url) -fn parse_markdown_lines(text: &str) -> Vec> { - let mut lines = Vec::new(); - let mut in_code_block = false; - - for line_text in text.lines() { - if line_text.starts_with("```") { - in_code_block = !in_code_block; - if in_code_block { - // Code block start with language hint - let lang = line_text.trim_start_matches("```").trim(); - lines.push(Line::from(vec![ - Span::styled( - "```", - Style::default().fg(semantic_color("muted", Color::DarkGray)), - ), - Span::styled( - lang.to_string(), - Style::default().fg(semantic_color("warning", Color::Yellow)), - ), - ])); - } else { - lines.push(Line::from(Span::styled( - "```", - Style::default().fg(semantic_color("muted", Color::DarkGray)), - ))); - } - continue; - } - - if in_code_block { - // Inside code block - render with dim style - lines.push(Line::from(Span::styled( - format!(" {line_text}"), - Style::default() - .fg(semantic_color("success", Color::Green)) - .add_modifier(Modifier::DIM), - ))); - } else { - // Parse inline markdown - lines.push(parse_markdown_line(line_text)); - } - } - - lines -} - -/// Parse a single line of markdown into styled spans -fn parse_markdown_line(text: &str) -> Line<'static> { - parse_markdown_line_with_theme(text, &crate::themes::current_theme()) -} - -fn parse_markdown_line_with_theme(text: &str, theme: &crate::themes::Theme) -> Line<'static> { - let mut spans = Vec::new(); - let mut current = String::new(); - let chars: Vec = text.chars().collect(); - let mut i = 0; - - while i < chars.len() { - // Check for bold (**text**) - if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' { - // Flush current - if !current.is_empty() { - spans.push(Span::raw(std::mem::take(&mut current))); - } - i += 2; - let start = i; - while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '*') { - i += 1; - } - let bold_text: String = chars[start..i].iter().collect(); - spans.push(Span::styled( - bold_text, - Style::default().add_modifier(Modifier::BOLD), - )); - if i + 1 < chars.len() { - i += 2; // skip closing ** - } - continue; - } - - // Check for inline code (`code`) - if chars[i] == '`' { - // Flush current - if !current.is_empty() { - spans.push(Span::raw(std::mem::take(&mut current))); - } - i += 1; - let start = i; - while i < chars.len() && chars[i] != '`' { - i += 1; - } - let code_text: String = chars[start..i].iter().collect(); - spans.push(Span::styled( - code_text, - if theme.name != "dark" || theme.canvas_style().bg.is_some() { - Style::default().fg(semantic_color_for_theme(theme, "md_code", Color::Cyan)) - } else { - Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM) - }, - )); - if i < chars.len() { - i += 1; // skip closing ` - } - continue; - } - - // Check for link [text](url) - if chars[i] == '[' { - // Flush current - if !current.is_empty() { - spans.push(Span::raw(std::mem::take(&mut current))); - } - i += 1; - let text_start = i; - while i < chars.len() && chars[i] != ']' { - i += 1; - } - let link_text: String = chars[text_start..i].iter().collect(); - i += 1; // skip ] - - if i < chars.len() && chars[i] == '(' { - i += 1; - let url_start = i; - while i < chars.len() && chars[i] != ')' { - i += 1; - } - let _url: String = chars[url_start..i].iter().collect(); - spans.push(Span::styled( - link_text, - Style::default() - .fg(semantic_color_for_theme(theme, "md_link", Color::Blue)) - .add_modifier(Modifier::UNDERLINED), - )); - if i < chars.len() { - i += 1; // skip ) - } - } else { - // Not a valid link, render as plain text - current.push('['); - current.push_str(&link_text); - current.push(']'); - } - continue; - } - - current.push(chars[i]); - i += 1; - } - - // Flush remaining - if !current.is_empty() { - spans.push(Span::raw(current)); - } - - if spans.is_empty() { - Line::default() - } else { - Line::from(spans) - } +/// Shared block layout for both message measurement and painting. +fn parse_markdown_lines(text: &str, width: usize) -> Vec> { + crate::markdown::render_markdown_with_width(text, Some(width.saturating_sub(2).max(1))).lines } /// Format a timestamp for display (HH:MM) @@ -382,7 +223,6 @@ fn tool_result_lines( } else { crate::tool_summary::summarize_tool_intent(&tc.tool, &tc.args) }; - let summary = format!("{summary} · {}", tc.tool); let arguments = get_tool_args_preview(&tc.tool, &tc.args, width.saturating_sub(20) as usize); let clamp = clamp_tool_output(&tc.output, tool_output_limits()); let banner = format_tool_output_truncation(&clamp); @@ -546,7 +386,7 @@ pub fn calculate_message_height( if message.is_compaction_boundary() { height += 1; if !message.content.is_empty() { - let md_lines = parse_markdown_lines(&message.content); + let md_lines = parse_markdown_lines(&message.content, content_width); let wrap_opts = RtOptions::new(content_width) .initial_indent(Line::from(" ")) .subsequent_indent(Line::from(" ")); @@ -562,16 +402,12 @@ pub fn calculate_message_height( // Thinking previews occupy one terminal row per source line. if !message.thinking.is_empty() { let count = message.thinking.lines().count(); - height += 1 + if message.thinking_expanded { - count - } else { - count.min(2) - } as u16; + height += 1 + if message.thinking_expanded { count } else { 0 } as u16; } // Content lines (with word wrapping) if !message.content.is_empty() { - let md_lines = parse_markdown_lines(&message.content); + let md_lines = parse_markdown_lines(&message.content, content_width); let wrap_opts = RtOptions::new(content_width) .initial_indent(Line::from(" ")) .subsequent_indent(Line::from(" ")); @@ -741,7 +577,8 @@ impl Widget for MessageWidget<'_> { y += 1; if y < max_y && !self.message.content.is_empty() { - let md_lines = parse_markdown_lines(&self.message.content); + let content_width = area.width.saturating_sub(4).max(1) as usize; + let md_lines = parse_markdown_lines(&self.message.content, content_width); let wrap_opts = RtOptions::new(area.width.saturating_sub(4).max(1) as usize) .initial_indent(Line::from(" ")) .subsequent_indent(Line::from(" ")); @@ -846,13 +683,7 @@ impl Widget for MessageWidget<'_> { maestro_ui::localization::tr("Thinking"), Style::default().fg(brand_violet()), ), - Span::styled( - maestro_ui::localization::format( - " ({0} chars) ", - &[(self.message.thinking.len()).to_string()], - ), - Style::default().fg(semantic_color("muted", Color::DarkGray)), - ), + Span::raw(" "), Span::styled( toggle_hint, Style::default() @@ -902,38 +733,6 @@ impl Widget for MessageWidget<'_> { ); y += 1; } - } else { - // Show first 2 lines of thinking as preview - let preview_lines: Vec<&str> = self.message.thinking.lines().take(2).collect(); - for line in preview_lines { - if y >= max_y { - break; - } - let max_len = area.width.saturating_sub(6) as usize; - let truncated = truncate_location(line, max_len); - let preview = Line::from(vec![ - Span::styled( - " │ ", - Style::default().fg(semantic_color("muted", Color::DarkGray)), - ), - Span::styled( - truncated, - Style::default() - .fg(semantic_color("muted", Color::DarkGray)) - .add_modifier(Modifier::ITALIC), - ), - ]); - Paragraph::new(preview).render( - Rect { - x: area.x, - y, - width: area.width, - height: 1, - }, - buf, - ); - y += 1; - } } } @@ -942,7 +741,7 @@ impl Widget for MessageWidget<'_> { let content_width = area.width.saturating_sub(4).max(1) as usize; // Parse markdown into styled lines - let md_lines = parse_markdown_lines(&self.message.content); + let md_lines = parse_markdown_lines(&self.message.content, content_width); // Word wrap all lines with indent let wrap_opts = RtOptions::new(content_width) @@ -1505,14 +1304,6 @@ impl<'a> ChatInputWidget<'a> { footer = candidate; } } - let hint = match mode { - InteractionMode::Plan => maestro_ui::localization::tr(" · /plan off to act"), - InteractionMode::Normal => maestro_ui::localization::tr(" · /plan to plan"), - InteractionMode::AlwaysApprove => "", - }; - if footer.width() + hint.width() <= available { - footer.push_str(hint); - } Some(footer) } @@ -1535,7 +1326,9 @@ impl<'a> ChatInputWidget<'a> { .map_or_else(Vec::new, |preview| { preview.build_lines(input_area.width.saturating_sub(2)) }); - self.shared(&queued, None).cursor_pos(input_area) + let footer = self.footer_for_width(input_area.width); + self.shared(&queued, footer.as_deref()) + .cursor_pos(input_area) } fn shared<'b>(&'b self, queued: &'b [Line<'static>], footer: Option<&'b str>) -> Composer<'b> { @@ -1563,7 +1356,7 @@ impl Widget for ChatInputWidget<'_> { } } -const MIN_TOTAL_INPUT_HEIGHT: u16 = 3; +const MIN_TOTAL_INPUT_HEIGHT: u16 = 4; const MAX_VISIBLE_INPUT_LINES: u16 = 6; const MIN_MESSAGES_HEIGHT: u16 = 3; @@ -1593,7 +1386,7 @@ pub(crate) fn calculate_input_height(state: &crate::state::AppState, area: Rect) let max_total_for_input = available_after_status .saturating_sub(MIN_MESSAGES_HEIGHT) .max(MIN_TOTAL_INPUT_HEIGHT); - let max_inner_for_input = max_total_for_input.saturating_sub(2).max(1); + let max_inner_for_input = max_total_for_input.saturating_sub(3).max(1); let visible_inner = desired_inner_lines .min(MAX_VISIBLE_INPUT_LINES) @@ -1601,7 +1394,7 @@ pub(crate) fn calculate_input_height(state: &crate::state::AppState, area: Rect) .max(1); visible_inner - .saturating_add(2) + .saturating_add(3) .max(MIN_TOTAL_INPUT_HEIGHT) .min(available_after_status) } @@ -2854,14 +2647,18 @@ impl ChatView<'_> { return; } + let content_area = Rect { + width: area.width.saturating_sub(1), + ..area + }; let layout = self.state.prepare_message_layout( - area.width, + content_area.width, self.message_layout_settings_key(), &renderable_messages, |index| { usize::from(calculate_message_height( renderable_messages[index], - area.width, + content_area.width, &self.state.expanded_tool_calls, self.state.compact_tool_outputs, self.state.focus_view, @@ -2904,7 +2701,7 @@ impl ChatView<'_> { let msg_height = usize::from(full_height) .saturating_sub(skip) .min(usize::from(max_y.saturating_sub(y))) as u16; - let msg_area = Rect::new(0, 0, area.width, full_height); + let msg_area = Rect::new(0, 0, content_area.width, full_height); let mut message_buffer = Buffer::empty(msg_area); message_buffer.set_style(msg_area, canvas); @@ -2920,7 +2717,7 @@ impl ChatView<'_> { .with_selected_focus_turn(self.state.focus_selected_turn.as_deref()); widget.render(msg_area, &mut message_buffer); for row in 0..msg_height { - for col in 0..area.width { + for col in 0..content_area.width { if let (Some(source), Some(target)) = ( message_buffer.cell((col, skip as u16 + row)), buf.cell_mut((area.x + col, y + row)), @@ -3036,59 +2833,6 @@ mod tests { assert_eq!(label.fg, crate::themes::current_ui_theme().attention); } - #[test] - fn inline_code_uses_readable_theme_ink_without_terminal_dimming() { - for name in [ - "light", - "green", - "pink", - "blue", - "green-dark", - "pink-dark", - "blue-dark", - ] { - let theme = crate::themes::load_theme(name).unwrap(); - let line = parse_markdown_line_with_theme("Run `cargo test`.", &theme); - let code = line - .spans - .iter() - .find(|span| span.content == "cargo test") - .unwrap(); - assert_eq!(code.style.fg, theme.get_color("md_code")); - assert!(!code.style.add_modifier.contains(Modifier::DIM)); - } - let dark = crate::themes::dark_theme(); - let code = parse_markdown_line_with_theme("`cargo test`", &dark); - assert_eq!(code.spans[0].style.fg, dark.get_color("md_code")); - assert!(!code.spans[0].style.add_modifier.contains(Modifier::DIM)); - } - - #[test] - fn markdown_links_respect_distinct_theme_link_colors() { - let mut custom = crate::themes::light_theme(); - custom.colors.md_heading = "#ff0000".into(); - custom.colors.md_link = "#00ff00".into(); - assert_ne!(custom.get_color("md_link"), custom.get_color("md_heading")); - for theme in [crate::themes::light_theme(), custom] { - let line = parse_markdown_line_with_theme("See [guide](https://example.com).", &theme); - let link = line - .spans - .iter() - .find(|span| span.content == "guide") - .unwrap(); - assert_eq!(link.style.fg, theme.get_color("md_link")); - assert!(link.style.add_modifier.contains(Modifier::UNDERLINED)); - } - let line = parse_markdown_line_with_theme( - "[guide](https://example.com)", - &crate::themes::dark_theme(), - ); - assert_eq!( - line.spans[0].style.fg, - crate::themes::dark_theme().get_color("md_link") - ); - } - fn polish_message(id: &str, content: &str) -> Message { Message { id: id.into(), @@ -3298,7 +3042,7 @@ mod tests { use std::fmt::Write as _; let mut content = String::new(); for i in 1..=40 { - writeln!(content, "Line {i:02}").unwrap(); + writeln!(content, "Line {i:02} ").unwrap(); } state.messages = vec![polish_message("long", &content)]; for (width, height) in [(80, 10), (40, 11), (100, 13)] { @@ -3338,7 +3082,7 @@ mod tests { .render(area, &mut buf); let text = buffer_lines(&buf, 100, 10).join("\n"); assert!(text.contains("Read README.md")); - assert!(text.contains("Useful preview")); + assert_eq!(text.contains("Useful preview"), expanded); assert_eq!(text.contains("read-private-id"), expanded); assert_eq!( text.contains(&format_timestamp(message.timestamp)), @@ -3589,7 +3333,7 @@ mod tests { let catalog_hits = rendered.matches("GPT-5.6").count(); assert_eq!(catalog_hits, 1, "model must appear once:\n{rendered}"); assert!(rendered.contains("Mode: Act")); - assert!(rendered.contains("/plan to plan")); + assert!(!rendered.contains("/plan to plan")); assert!(!rendered.contains("via openai-codex")); assert!(!rendered.contains("openai-codex/gpt-5.6")); assert!(!rendered.contains("Describe what you want to build...")); @@ -3622,7 +3366,7 @@ mod tests { "mode missing: {lines:?}" ); if width == 100 && mode == InteractionMode::Plan { - assert!(lines.iter().any(|line| line.contains("/plan off to act"))); + assert!(!lines.iter().any(|line| line.contains("/plan off to act"))); } } } @@ -3826,9 +3570,10 @@ mod tests { .render(buf.area, &mut buf); let rendered = buffer_lines(&buf, width, height).join("\n"); assert!(rendered.contains("Read package.json")); - assert!(rendered.contains("· read")); - assert!( - rendered.contains("/Users/jonathanhaas/Documents/Projects/maestro/package.json") + assert_eq!(rendered.contains("· read"), !compact); + assert_eq!( + rendered.contains("/Users/jonathanhaas/Documents/Projects/maestro/package.json"), + !compact ); } } @@ -3922,7 +3667,8 @@ mod tests { let rendered = buffer_lines(&buf, width, height).join("\n"); assert!(rendered.contains("Ran cargo test")); - assert!(rendered.contains("finished")); + assert!(!rendered.contains("finished")); + assert!(rendered.contains("not found")); assert!(rendered.contains("Failed · Search for \"needle\"")); } @@ -4114,7 +3860,7 @@ mod dex_notice_layout_tests { use super::*; #[test] - fn quiet_notice_does_not_overwrite_centered_welcome_status() { + fn quiet_notice_does_not_overwrite_welcome_identity() { let state = crate::state::AppState::new(); let area = Rect::new(0, 0, 60, 20); let mut buffer = Buffer::empty(area); @@ -4139,7 +3885,7 @@ mod dex_notice_layout_tests { .unwrap(); let status = lines .iter() - .position(|line| line.contains("Dex · ready")) + .position(|line| line.contains(super::super::deixic_logo::PRODUCT_TITLE)) .unwrap(); assert_ne!(notice, status); } diff --git a/packages/tui-rs/src/components/theme_selector.rs b/packages/tui-rs/src/components/theme_selector.rs index 0fff82a4e..e1983f134 100644 --- a/packages/tui-rs/src/components/theme_selector.rs +++ b/packages/tui-rs/src/components/theme_selector.rs @@ -4,8 +4,8 @@ use crossterm::event::KeyCode; use maestro_ui::{ActionPicker, KeyHint, Modal, ModalSize, PickerOptions, PickerOutcome}; use ratatui::{ Frame, - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, + layout::Rect, + style::Style, text::{Line, Span}, widgets::ListItem, }; @@ -85,22 +85,10 @@ impl ThemeSelector { ) .theme(theme) .render(frame, area); - let (picker_area, preview_area) = if inner.height >= 12 { - let chunks = Layout::vertical([Constraint::Min(5), Constraint::Length(7)]).split(inner); - (chunks[0], Some(chunks[1])) - } else { - (inner, None) - }; - if let Some(area) = preview_area { - frame.render_widget( - maestro_presentation::components::theme_preview::ThemePreview(theme), - area, - ); - } let current = &self.current_theme; self.picker.render( frame, - picker_area, + inner, theme, PickerOptions { placeholder: maestro_ui::localization::tr("Type to filter themes..."), @@ -113,10 +101,7 @@ impl ThemeSelector { ..PickerOptions::default() }, |name| { - let mut spans = vec![Span::styled( - name.as_str(), - Style::default().add_modifier(Modifier::BOLD), - )]; + let mut spans = vec![Span::styled(name.as_str(), Style::default())]; if current.as_ref().is_some_and(|c| c == name) { spans.push(Span::styled( maestro_ui::localization::tr(" (current)"), @@ -154,7 +139,7 @@ mod tests { } #[test] - fn picker_renders_the_same_sample_at_wide_and_narrow_sizes() { + fn picker_keeps_filter_and_choices_without_a_nested_sample() { use ratatui::{Terminal, backend::TestBackend}; for (width, height) in [(100, 30), (60, 20)] { let mut selector = ThemeSelector::new(); @@ -169,9 +154,9 @@ mod tests { .iter() .map(|c| c.symbol()) .collect(); - assert!(text.contains("Dex · ready")); - assert!(text.contains("Let's make something useful.")); - assert!(text.contains("Ask Dex")); + assert!(text.contains("Select Theme")); + assert!(text.contains("Type to filter themes")); + assert!(!text.contains("Ask Dex")); assert_eq!(selector.original_theme().unwrap().name, original); } } diff --git a/packages/tui-rs/src/components/welcome.rs b/packages/tui-rs/src/components/welcome.rs index 3377303e6..372548d19 100644 --- a/packages/tui-rs/src/components/welcome.rs +++ b/packages/tui-rs/src/components/welcome.rs @@ -117,6 +117,31 @@ impl WelcomeScreen { /// Build the content lines fn build_content(&self, area: Rect) -> Vec> { + if self.personality == super::dex_companion::DexPersonality::Quiet { + let theme = crate::themes::current_ui_theme(); + let mut lines = vec![Line::styled( + self.welcome_message + .as_deref() + .unwrap_or(super::deixic_logo::PRODUCT_TITLE) + .to_owned(), + Style::default().fg(theme.text).add_modifier(Modifier::BOLD), + )]; + for (label, value) in [("version", &self.version), ("model", &self.model)] { + if let Some(value) = value { + lines.push(Line::styled( + format!("{label} {value}"), + Style::default().fg(theme.muted), + )); + } + } + if let Some((runtime, location)) = &self.summary { + lines.push(Line::styled( + format!("{runtime} · {location}"), + Style::default().fg(theme.muted), + )); + } + return lines; + } // Brand block: Dex mark + Dex Code title. // Custom welcome_message replaces only the product title line. // Reserve rows for optional onboarding metadata before selecting the @@ -127,23 +152,7 @@ impl WelcomeScreen { + u16::from(self.session_id.is_some()) + 1; let brand_height = area.height.saturating_sub(reserved_rows); - let mut lines = if self.personality == super::dex_companion::DexPersonality::Quiet { - vec![ - if crate::themes::current_theme().canvas_style().bg.is_some() { - Line::styled( - super::deixic_logo::PRODUCT_TITLE, - Style::default() - .fg(crate::themes::current_ui_theme().text) - .add_modifier(Modifier::BOLD), - ) - } else { - super::deixic_logo::product_title_line(false) - }, - Line::from(super::deixic_logo::COMPOSER_HINT), - ] - } else { - super::deixic_logo::welcome_content_lines(brand_height, false) - }; + let mut lines = super::deixic_logo::welcome_content_lines(brand_height, false); // An empty session is ready, even when decorative motion is enabled. lines.push( super::dex_companion::DexCompanion::new(super::dex_companion::DexCompanionState::Ready) @@ -215,22 +224,28 @@ impl Widget for WelcomeScreen { Clear.render(area, buf); buf.set_style(area, crate::themes::current_theme().canvas_style()); - let content = crate::wrapping::word_wrap_lines( - &self.build_content(area), - usize::from(area.width.max(1)), - ); + let quiet = self.personality == super::dex_companion::DexPersonality::Quiet; + let width = area.width.saturating_sub(if quiet { 2 } else { 0 }); + let content = + crate::wrapping::word_wrap_lines(&self.build_content(area), usize::from(width.max(1))); let content_height = content.len().min(usize::from(area.height)) as u16; - let paragraph = Paragraph::new(content).alignment(Alignment::Center); - let y_offset = if area.height > content_height { + let paragraph = Paragraph::new(content).alignment(if quiet { + Alignment::Left + } else { + Alignment::Center + }); + let y_offset = if quiet { + 1.min(area.height.saturating_sub(content_height)) + } else if area.height > content_height { (area.height - content_height) / 2 } else { 0 }; let content_area = Rect::new( - area.x, + area.x + if quiet { 2.min(area.width) } else { 0 }, area.y + y_offset, - area.width, + area.width.saturating_sub(if quiet { 2 } else { 0 }), content_height.min(area.height), ); @@ -539,17 +554,17 @@ mod tests { } #[test] - fn quiet_welcome_keeps_identity_and_ready_text_without_art() { + fn quiet_welcome_keeps_identity_without_art_or_redundant_status() { let lines = WelcomeScreen::new() .personality(super::super::dex_companion::DexPersonality::Quiet) .animations(true) .build_content(Rect::new(0, 0, 80, 24)); - assert_eq!(lines.len(), 3); + assert_eq!(lines.len(), 1); assert_eq!( lines[0].to_string(), super::super::deixic_logo::PRODUCT_TITLE ); - assert_eq!(lines[2].to_string(), "Dex · ready"); + assert!(!lines.iter().any(|line| line.to_string().contains("ready"))); } fn rendered_welcome(welcome: WelcomeScreen, area: Rect) -> String { @@ -566,14 +581,18 @@ mod tests { } #[test] - fn quiet_narrow_welcome_preserves_ready_after_wrapped_hint() { + fn quiet_narrow_welcome_preserves_wrapped_project_context() { let rendered = rendered_welcome( - WelcomeScreen::new().personality(super::super::dex_companion::DexPersonality::Quiet), + WelcomeScreen::new() + .personality(super::super::dex_companion::DexPersonality::Quiet) + .with_summary("GPT-6 Astra".into(), "a-long-project-directory".into()), Rect::new(0, 0, 32, 12), ); let normalized = rendered.split_whitespace().collect::>().join(" "); - assert!(normalized.contains(super::super::deixic_logo::COMPOSER_HINT)); - assert!(rendered.contains("Dex · ready")); + assert!(normalized.contains("GPT-6 Astra")); + let compact: String = rendered.chars().filter(|ch| !ch.is_whitespace()).collect(); + assert!(compact.contains("a-long-project-directory"), "{rendered}"); + assert!(!rendered.contains("Dex · ready")); } #[test] @@ -586,11 +605,11 @@ mod tests { .with_model("a-long-model-name-that-needs-a-second-row"), Rect::new(0, 0, 32, 16), ); - assert!(rendered.contains("Dex · ready")); + assert!(!rendered.contains("Dex · ready")); assert!(rendered.contains("version 1.0.0")); let compact: String = rendered.chars().filter(|ch| !ch.is_whitespace()).collect(); assert!(compact.contains("modela-long-model-name-that-needs-a-second-row")); - assert!(rendered.contains("terminal rows")); + assert!(compact.contains("terminalrows"), "{rendered}"); } #[test] diff --git a/packages/tui-rs/src/config_cli.rs b/packages/tui-rs/src/config_cli.rs index 6d2c71fe8..31c7783f0 100644 --- a/packages/tui-rs/src/config_cli.rs +++ b/packages/tui-rs/src/config_cli.rs @@ -68,6 +68,7 @@ pub async fn run_config(args: &[String]) -> Result { println!("{}", config_help()); Ok(0) } + "deployment" => run_deployment(&args[1..]), "path" | "paths" => run_path(&args[1..]), "list" | "ls" => run_list(&args[1..]), "get" => run_get(&args[1..]), @@ -103,6 +104,7 @@ Commands: set [--scope ...] Write a dotted TOML key show | status Inspect sources and print a secret-safe generation digest + deployment --json Inspect the signed disconnected profile without network requests validate Validate provider JSON + TOML config files init [--preset ] [--force] Create project .maestro/config.json @@ -114,6 +116,17 @@ Options: --help, -h Show this help" } +fn run_deployment(args: &[String]) -> Result { + anyhow::ensure!( + args == ["--json"], + "Usage: deixic-code config deployment --json" + ); + let contract = maestro_local_host::safety::disconnected_deployment_contract() + .map_err(anyhow::Error::msg)?; + println!("{}", serde_json::to_string_pretty(&contract)?); + Ok(0) +} + fn run_path(args: &[String]) -> Result { let json = args.iter().any(|arg| arg == "--json"); let entries = config_path_entries()?; @@ -1469,7 +1482,7 @@ fn validate_config() -> Result { if toml_path.exists() { config_files.push(toml_path.display().to_string()); if let Err(error) = fs::read_to_string(&toml_path).and_then(|raw| { - raw.parse::() + toml::from_str::(&raw) .map(|_| ()) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) }) { @@ -1485,7 +1498,7 @@ fn validate_config() -> Result { } config_files.push(path.display().to_string()); if let Err(error) = fs::read_to_string(&path).and_then(|raw| { - raw.parse::() + toml::from_str::(&raw) .map(|_| ()) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) }) { @@ -1731,14 +1744,14 @@ fn load_toml_for_update(path: &Path) -> Result { } }; // Parser diagnostics can contain config values. Keep those out of the TUI. - raw.parse::() + toml::from_str::(&raw) .map_err(|_| anyhow::anyhow!("invalid TOML in {}; file left unchanged", path.display())) } fn load_toml(path: &Path) -> Option { fs::read_to_string(path) .ok() - .and_then(|raw| raw.parse::().ok()) + .and_then(|raw| toml::from_str::(&raw).ok()) } fn flatten_keys(prefix: &str, value: &TomlValue, out: &mut Vec) { @@ -2389,6 +2402,24 @@ mod tests { } } + #[test] + fn deployment_inspection_rejects_mutation_and_probe_flags() { + for args in [ + vec![], + vec!["--live"], + vec!["--json", "--live"], + vec!["--json", "--set"], + ] { + let args = args.into_iter().map(str::to_owned).collect::>(); + assert!( + run_deployment(&args) + .unwrap_err() + .to_string() + .contains("Usage:") + ); + } + } + #[test] fn parse_cli_value_handles_scalars_and_strings() { assert_eq!(parse_cli_value("true"), TomlValue::Boolean(true)); @@ -2427,6 +2458,28 @@ mod tests { ); } + #[test] + fn config_file_loaders_parse_documents_without_process_state() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("config.toml"); + for document in [ + "", + "theme = \"dark\"\n", + "[history]\npersistence = \"save-all\"\n", + ] { + fs::write(&path, document).unwrap(); + let expected: TomlValue = toml::from_str(document).unwrap(); + assert_eq!(load_toml(&path), Some(expected.clone())); + assert_eq!(load_toml_for_update(&path).unwrap(), expected); + } + let invalid = "secret_fixture = \"do-not-echo\"\n[broken\n"; + fs::write(&path, invalid).unwrap(); + assert!(load_toml(&path).is_none()); + let error = load_toml_for_update(&path).unwrap_err(); + assert!(!format!("{error:#}").contains("do-not-echo")); + assert_eq!(fs::read_to_string(&path).unwrap(), invalid); + } + #[test] fn config_updates_preserve_invalid_or_unreadable_settings() { if crate::config::test_reexec_for_process_isolation() { diff --git a/packages/tui-rs/src/entrypoint.rs b/packages/tui-rs/src/entrypoint.rs index a42b4c95e..5a9ff0dfa 100644 --- a/packages/tui-rs/src/entrypoint.rs +++ b/packages/tui-rs/src/entrypoint.rs @@ -481,6 +481,30 @@ struct Args { #[arg(long)] json: bool, + /// Print the native tool catalog and exit. + #[arg(long)] + list_tools: bool, + + /// Restrict a print run to these comma- or space-separated tool names. + #[arg(long, requires = "print", action = clap::ArgAction::Append)] + only_tools: Vec, + + /// Add tools to a print run without widening configured policy ceilings. + #[arg(long, requires = "print", action = clap::ArgAction::Append)] + add_tools: Vec, + + /// Remove tools from a print run. + #[arg(long, requires = "print", action = clap::ArgAction::Append)] + remove_tools: Vec, + + /// Attach a name or JSON object to this run; repeatable. + #[arg(long, requires = "print", action = clap::ArgAction::Append)] + tag: Vec, + + /// Correlate this run with related CI, release, or batch runs. + #[arg(long, requires = "print")] + log_group_id: Option, + /// Run as native headless protocol server on stdio. #[arg(long)] headless: bool, @@ -516,6 +540,12 @@ struct NativeExecOptions { prompt_stdin: bool, prompt: String, plugins: Vec, + list_tools: bool, + only_tools: Vec, + add_tools: Vec, + remove_tools: Vec, + tags: Vec, + log_group_id: Option, } fn parse_native_exec_options(raw_args: &[std::ffi::OsString]) -> NativeExecOptions { @@ -531,6 +561,36 @@ fn parse_native_exec_options(raw_args: &[std::ffi::OsString]) -> NativeExecOptio positional_only = true; } else if arg == "--json" || arg == "--mode=json" { options.json = true; + } else if arg == "--list-tools" { + options.list_tools = true; + } else if matches!( + arg.as_ref(), + "--only-tools" | "--add-tools" | "--remove-tools" | "--tag" | "--log-group-id" + ) { + let flag = arg.into_owned(); + i += 1; + let value = raw_args + .get(i) + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_default(); + match flag.as_str() { + "--only-tools" => options.only_tools.push(value), + "--add-tools" => options.add_tools.push(value), + "--remove-tools" => options.remove_tools.push(value), + "--tag" => options.tags.push(value), + "--log-group-id" => options.log_group_id = Some(value), + _ => unreachable!(), + } + } else if let Some(value) = arg.strip_prefix("--only-tools=") { + options.only_tools.push(value.to_string()); + } else if let Some(value) = arg.strip_prefix("--add-tools=") { + options.add_tools.push(value.to_string()); + } else if let Some(value) = arg.strip_prefix("--remove-tools=") { + options.remove_tools.push(value.to_string()); + } else if let Some(value) = arg.strip_prefix("--tag=") { + options.tags.push(value.to_string()); + } else if let Some(value) = arg.strip_prefix("--log-group-id=") { + options.log_group_id = Some(value.to_string()); } else if arg == "--mode" { i += 1; if i < raw_args.len() && raw_args[i] == "json" { @@ -624,6 +684,57 @@ fn parse_native_exec_options(raw_args: &[std::ffi::OsString]) -> NativeExecOptio options } +fn parse_run_tags(values: &[String]) -> std::result::Result, String> { + if values.len() > 32 { + return Err("at most 32 --tag values are allowed".to_owned()); + } + values + .iter() + .map(|value| { + let value = value.trim(); + if value.is_empty() { + return Err("--tag must not be empty".to_owned()); + } + if value.len() > 512 { + return Err("--tag values must not exceed 512 bytes".to_owned()); + } + if !value.starts_with('{') { + return Ok(serde_json::Value::String(value.to_owned())); + } + let parsed: serde_json::Value = serde_json::from_str(value) + .map_err(|error| format!("invalid --tag JSON: {error}"))?; + let name = parsed + .get("name") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()); + if name.is_none() { + return Err("structured --tag JSON requires a non-empty `name`".to_owned()); + } + Ok(parsed) + }) + .collect() +} + +fn parse_log_group_id(value: Option) -> std::result::Result, String> { + value + .map(|value| value.trim().to_owned()) + .map(|value| { + if value.is_empty() + || value.len() > 128 + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() || "-_.:/".contains(character) + }) + { + return Err( + "--log-group-id must be 1-128 ASCII letters, digits, or -_.:/".to_owned(), + ); + } + Ok(value) + }) + .transpose() +} + fn native_exec_sandbox_policy( value: Option<&str>, ) -> std::result::Result, String> { @@ -813,6 +924,11 @@ pub async fn run_cli(raw_args: Vec) -> Result<()> { output_schema: None, sandbox_policy: None, fail_on_approval: false, + only_tools: Vec::new(), + add_tools: Vec::new(), + remove_tools: Vec::new(), + tags: Vec::new(), + log_group_id: None, }) .await?; crate::telemetry::flush_first_party_telemetry().await; @@ -1002,6 +1118,10 @@ async fn run_agent(raw_args: Vec) -> Result { // maestro print|exec [--json] [--model X] [--output-last-message P] // [--output-schema S] let mut options = parse_native_exec_options(&raw_args[2..]); + if options.list_tools { + crate::print_mode::print_available_tools(options.json)?; + return Ok(0); + } let env_sandbox = std::env::var("MAESTRO_SANDBOX_MODE").ok(); let sandbox_value = options.sandbox.as_deref().or(env_sandbox.as_deref()); let sandbox_policy = match native_exec_sandbox_policy(sandbox_value) { @@ -1029,10 +1149,24 @@ async fn run_agent(raw_args: Vec) -> Result { } if options.prompt.is_empty() { eprintln!( - "Usage: deixic-code {cmd} [--json] [--model ] [--specialist ] [--output-last-message ] [--output-schema ] " + "Usage: deixic-code {cmd} [--json] [--model ] [--only-tools ] [--add-tools ] [--remove-tools ] [--tag ] [--log-group-id ] " ); return Ok(2); } + let tags = match parse_run_tags(&options.tags) { + Ok(tags) => tags, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + let log_group_id = match parse_log_group_id(options.log_group_id) { + Ok(log_group_id) => log_group_id, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; let model = match native_exec_model(options.provider.as_deref(), options.model.as_deref()) { Ok(model) => model, @@ -1051,6 +1185,11 @@ async fn run_agent(raw_args: Vec) -> Result { output_schema: options.output_schema, sandbox_policy, fail_on_approval: options.approval_mode.as_deref() == Some("fail"), + only_tools: options.only_tools, + add_tools: options.add_tools, + remove_tools: options.remove_tools, + tags, + log_group_id, }) .await?; return Ok(code); @@ -1157,6 +1296,11 @@ async fn run_agent(raw_args: Vec) -> Result { return Ok(code); } + if args.list_tools { + crate::print_mode::print_available_tools(args.json)?; + return Ok(0); + } + // Non-interactive print mode (single-shot / exec bridge) if args.print { let prompt = initial_prompt.unwrap_or_default(); @@ -1164,6 +1308,8 @@ async fn run_agent(raw_args: Vec) -> Result { eprintln!("--print requires a prompt"); return Ok(2); } + let tags = parse_run_tags(&args.tag).map_err(anyhow::Error::msg)?; + let log_group_id = parse_log_group_id(args.log_group_id).map_err(anyhow::Error::msg)?; let code = crate::print_mode::run_print_mode(crate::print_mode::PrintModeOptions { specialist: args.specialist, prompt, @@ -1176,6 +1322,11 @@ async fn run_agent(raw_args: Vec) -> Result { output_schema: args.output_schema.clone(), sandbox_policy: None, fail_on_approval: false, + only_tools: args.only_tools, + add_tools: args.add_tools, + remove_tools: args.remove_tools, + tags, + log_group_id, }) .await?; return Ok(code); @@ -1557,6 +1708,47 @@ mod tests { assert!(args.print); assert!(args.json); assert_eq!(args.prompt, vec!["hello"]); + + let tools = Args::try_parse_from(["maestro-tui", "--list-tools", "--json"]) + .expect("parse top-level tool listing"); + assert!(tools.list_tools); + assert!(!tools.print); + } + + #[test] + fn one_run_tools_and_lineage_are_parsed_without_entering_the_prompt() { + let args = [ + "--only-tools=read,bash", + "--add-tools", + "write", + "--remove-tools=write", + "--tag", + "reviewed", + "--tag={\"name\":\"lane\",\"value\":\"release\"}", + "--log-group-id", + "deploy:42", + "ship", + ] + .into_iter() + .map(std::ffi::OsString::from) + .collect::>(); + let options = parse_native_exec_options(&args); + assert_eq!(options.only_tools, vec!["read,bash"]); + assert_eq!(options.add_tools, vec!["write"]); + assert_eq!(options.remove_tools, vec!["write"]); + assert_eq!(options.tags.len(), 2); + assert_eq!(options.log_group_id.as_deref(), Some("deploy:42")); + assert_eq!(options.prompt, "ship"); + + let tags = parse_run_tags(&options.tags).expect("valid run tags"); + assert_eq!(tags[0], serde_json::json!("reviewed")); + assert_eq!(tags[1]["name"], "lane"); + assert!(parse_run_tags(&["{\"value\":1}".to_string()]).is_err()); + assert_eq!( + parse_log_group_id(options.log_group_id).unwrap().as_deref(), + Some("deploy:42") + ); + assert!(parse_log_group_id(Some("bad group".to_string())).is_err()); } #[test] diff --git a/packages/tui-rs/src/markdown.rs b/packages/tui-rs/src/markdown.rs index 0e60b4903..fa5a4dcc9 100644 --- a/packages/tui-rs/src/markdown.rs +++ b/packages/tui-rs/src/markdown.rs @@ -15,14 +15,14 @@ //! //! # Supported Features //! -//! - **Headings** (H1-H6): Different styles with # prefix preserved +//! - **Headings** (H1-H6): Bold semantic headings without source markers //! - **Code blocks**: Fenced code with language-specific syntax highlighting //! - **Inline code**: Backtick-delimited code with distinct styling //! - **Lists**: Both ordered and unordered, with proper indentation //! - **Emphasis**: Italic (*text*), bold (**text**), strikethrough (~~text~~) //! - **Links**: Displayed as styled text with URL appended in parentheses //! - **Blockquotes**: Rendered with vertical bar prefix -//! - **Tables**: Parsed but basic rendering support +//! - **Tables**: Width-aware columns with stacked fields on narrow terminals //! - **Horizontal rules**: Rendered as separator lines //! //! # External Crates @@ -46,8 +46,8 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use crate::hyperlink; -use crate::palette::theme; use crate::syntax; +use maestro_ui::wrapping::{RtOptions, word_wrap_lines}; /// Style configuration for markdown elements. /// @@ -76,23 +76,29 @@ pub struct MarkdownStyles { impl Default for MarkdownStyles { fn default() -> Self { + Self::for_theme(&crate::themes::current_theme()) + } +} + +impl MarkdownStyles { + fn for_theme(theme: &crate::themes::Theme) -> Self { + let foreground = |name| Style::default().fg(theme.get_color(name).unwrap_or(Color::Reset)); + let heading = foreground("md_heading").add_modifier(Modifier::BOLD); Self { - h1: Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED), - h2: Style::default().add_modifier(Modifier::BOLD), - h3: Style::default().add_modifier(Modifier::BOLD | Modifier::ITALIC), - h4: Style::default().add_modifier(Modifier::ITALIC), - h5: Style::default().add_modifier(Modifier::ITALIC), - h6: Style::default().add_modifier(Modifier::ITALIC | Modifier::DIM), - code: Style::default().fg(Color::Cyan), - code_block: Style::default().fg(theme::syntax_string()), + h1: heading, + h2: heading, + h3: heading, + h4: heading, + h5: heading, + h6: heading, + code: foreground("md_code"), + code_block: foreground("text"), emphasis: Style::default().add_modifier(Modifier::ITALIC), strong: Style::default().add_modifier(Modifier::BOLD), strikethrough: Style::default().add_modifier(Modifier::CROSSED_OUT), - link: Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::UNDERLINED), - blockquote: Style::default().fg(Color::Green), - list_marker: Style::default().fg(Color::Blue), + link: foreground("md_link").add_modifier(Modifier::UNDERLINED), + blockquote: foreground("muted"), + list_marker: foreground("muted"), } } } @@ -126,22 +132,111 @@ pub fn render_markdown(input: &str) -> Text<'static> { /// Render markdown with optional width limit for wrapping. /// /// This function provides the same functionality as `render_markdown` but with -/// an optional width parameter for future word-wrapping support. -/// -/// Note: The width parameter is currently unused but reserved for future implementation -/// of automatic line wrapping at the markdown rendering level. +/// an optional cell width for table layout. The caller owns outer paragraph wrapping. #[must_use] -pub fn render_markdown_with_width(input: &str, _width: Option) -> Text<'static> { +pub fn render_markdown_with_width(input: &str, width: Option) -> Text<'static> { + render_with_styles(input, width, MarkdownStyles::default()) +} + +fn render_with_styles(input: &str, width: Option, styles: MarkdownStyles) -> Text<'static> { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); let parser = Parser::new_ext(input, options); - let mut renderer = MarkdownRenderer::new(); + let mut renderer = MarkdownRenderer::new(styles, width.unwrap_or(80).max(1)); renderer.render(parser); renderer.into_text() } +/// Table cells keep their styled text and fit the same width used by the transcript. +#[derive(Default)] +struct TableLayout { + rows: Vec>>, + row: Vec>, +} + +impl TableLayout { + fn render(self, width: usize, muted: Style) -> Vec> { + let columns = self.rows.iter().map(Vec::len).max().unwrap_or(0); + if columns == 0 { + return Vec::new(); + } + let mut widths = vec![1; columns]; + for row in &self.rows { + for (i, cell) in row.iter().enumerate() { + widths[i] = widths[i].max(cell.width()); + } + } + let gaps = columns.saturating_sub(1) * 2; + // At small widths, stacked labeled fields retain every cell instead of clipping. + if width < columns * 8 + gaps { + let mut lines = Vec::new(); + let headers = &self.rows[0]; + if self.rows.len() == 1 { + return headers + .iter() + .flat_map(|cell| { + word_wrap_lines(std::slice::from_ref(cell), RtOptions::new(width)) + }) + .collect(); + } + for row in self.rows.iter().skip(1) { + for (i, cell) in row.iter().enumerate() { + let mut spans = headers.get(i).map_or_else(Vec::new, |h| h.spans.clone()); + spans.push(Span::styled(": ", muted)); + spans.extend(cell.spans.clone()); + lines.extend(word_wrap_lines(&[Line::from(spans)], RtOptions::new(width))); + } + lines.push(Line::from("")); + } + return lines; + } + while widths.iter().sum::() + gaps > width { + let Some((i, longest)) = widths.iter().enumerate().max_by_key(|(_, n)| *n) else { + break; + }; + if *longest <= 1 { + break; + } + widths[i] -= 1; + } + let mut lines = Vec::new(); + for (row_index, row) in self.rows.into_iter().enumerate() { + let cells: Vec>> = (0..columns) + .map(|i| { + let cell = row.get(i).cloned().unwrap_or_default(); + word_wrap_lines(&[cell], RtOptions::new(widths[i])) + }) + .collect(); + let height = cells.iter().map(Vec::len).max().unwrap_or(1).max(1); + for y in 0..height { + let mut spans = Vec::new(); + for (i, cell) in cells.iter().enumerate() { + let line = cell.get(y).cloned().unwrap_or_default(); + let used = line.width(); + spans.extend(line.spans); + if i + 1 < columns { + spans.push(Span::raw(" ".repeat(widths[i].saturating_sub(used) + 2))); + } + } + lines.push(Line::from(spans)); + } + if row_index == 0 { + lines.push(Line::styled( + widths + .iter() + .map(|w| "─".repeat(*w)) + .collect::>() + .join(" "), + muted, + )); + } + } + lines + } +} + /// Internal renderer state for processing markdown events. /// /// This struct maintains rendering state as it processes the stream of events from @@ -180,12 +275,14 @@ struct MarkdownRenderer { blockquote_depth: usize, /// Current link target and visible label while parsing `[label](url)`. current_link: Option, + width: usize, + table: Option, } impl MarkdownRenderer { - fn new() -> Self { + fn new(styles: MarkdownStyles, width: usize) -> Self { Self { - styles: MarkdownStyles::default(), + styles, lines: Vec::new(), current_spans: Vec::new(), style_stack: vec![Style::default()], @@ -195,6 +292,8 @@ impl MarkdownRenderer { code_block_lang: None, blockquote_depth: 0, current_link: None, + width, + table: None, } } @@ -222,12 +321,6 @@ impl MarkdownRenderer { spans.push(Span::styled("│ ", self.styles.blockquote)); } - // Add list prefix if needed - if !self.list_stack.is_empty() { - let indent = " ".repeat(self.list_stack.len() - 1); - spans.push(Span::raw(indent)); - } - spans.append(&mut self.current_spans); self.lines.push(Line::from(spans)); } @@ -249,13 +342,13 @@ impl MarkdownRenderer { fn add_inline_code(&mut self, code: &str) { if let Some(link) = self.current_link.as_mut() { - let code_label = format!("`{code}`"); - link.label.push_str(&code_label); - link.spans.push(Span::styled(code_label, self.styles.code)); + link.label.push_str(code); + link.spans + .push(Span::styled(code.to_owned(), self.styles.code)); return; } self.current_spans - .push(Span::styled(format!("`{code}`"), self.styles.code)); + .push(Span::styled(code.to_owned(), self.styles.code)); } fn add_soft_break(&mut self) { @@ -325,8 +418,8 @@ impl MarkdownRenderer { Event::Rule => { self.flush_line(); self.lines.push(Line::from(Span::styled( - "─".repeat(40), - Style::default().fg(Color::DarkGray), + "─".repeat(self.width.min(40)), + self.styles.blockquote, ))); } _ => {} @@ -348,22 +441,15 @@ impl MarkdownRenderer { HeadingLevel::H6 => self.styles.h6, }; self.push_style(style); - - // Add heading prefix - let prefix = match level { - HeadingLevel::H1 => "# ", - HeadingLevel::H2 => "## ", - HeadingLevel::H3 => "### ", - HeadingLevel::H4 => "#### ", - HeadingLevel::H5 => "##### ", - HeadingLevel::H6 => "###### ", - }; - self.current_spans - .push(Span::styled(prefix.to_string(), style)); } - Tag::Paragraph => { + Tag::Paragraph if self.list_stack.is_empty() => { + self.flush_line(); + } + Tag::Table(_) => { self.flush_line(); + self.table = Some(TableLayout::default()); } + Tag::TableHead => self.push_style(self.styles.strong), Tag::BlockQuote(_) => { self.flush_line(); self.blockquote_depth += 1; @@ -389,7 +475,7 @@ impl MarkdownRenderer { *n += 1; marker } else { - "* ".to_string() + "• ".to_string() }; let indent = " ".repeat(self.list_stack.len().saturating_sub(1)); self.current_spans.push(Span::styled( @@ -428,7 +514,31 @@ impl MarkdownRenderer { } TagEnd::Paragraph => { self.flush_line(); - self.lines.push(Line::from("")); // blank line after paragraph + if self.list_stack.is_empty() { + self.lines.push(Line::from("")); + } + } + TagEnd::TableCell => { + if let Some(table) = &mut self.table { + table + .row + .push(Line::from(std::mem::take(&mut self.current_spans))); + } + } + TagEnd::TableHead | TagEnd::TableRow => { + if matches!(tag, TagEnd::TableHead) { + self.pop_style(); + } + if let Some(table) = &mut self.table { + table.rows.push(std::mem::take(&mut table.row)); + } + } + TagEnd::Table => { + if let Some(table) = self.table.take() { + self.lines + .extend(table.render(self.width, self.styles.blockquote)); + self.lines.push(Line::from("")); + } } TagEnd::BlockQuote(_) => { self.blockquote_depth = self.blockquote_depth.saturating_sub(1); @@ -436,32 +546,14 @@ impl MarkdownRenderer { } TagEnd::CodeBlock => { self.in_code_block = false; - // Render code block with border and syntax highlighting let lang = self.code_block_lang.as_deref(); - let lang_label = lang.unwrap_or("code"); - - // Header - self.lines.push(Line::from(vec![ - Span::styled("┌─ ", Style::default().fg(Color::DarkGray)), - Span::styled(lang_label.to_string(), Style::default().fg(Color::DarkGray)), - Span::styled(" ─", Style::default().fg(Color::DarkGray)), - ])); - - // Syntax-highlighted content - let highlighted_lines = syntax::highlight_code(&self.code_block_content, lang); - for mut line in highlighted_lines { - // Prepend the border - let mut spans = vec![Span::styled("│ ", Style::default().fg(Color::DarkGray))]; - spans.append(&mut line.spans); - self.lines.push(Line::from(spans)); + if let Some(label) = lang { + self.lines + .push(Line::styled(label.to_owned(), self.styles.blockquote)); } - - // Footer - self.lines.push(Line::from(Span::styled( - "└──────", - Style::default().fg(Color::DarkGray), - ))); - self.lines.push(Line::from("")); // blank line after code block + self.lines + .extend(syntax::highlight_code(&self.code_block_content, lang)); + self.lines.push(Line::from("")); self.code_block_content.clear(); self.code_block_lang = None; @@ -492,7 +584,7 @@ impl MarkdownRenderer { if !link.url.starts_with("file://") { self.current_spans.push(Span::styled( format!(" ({})", link.url), - Style::default().fg(Color::DarkGray), + self.styles.blockquote, )); } return; @@ -506,7 +598,7 @@ impl MarkdownRenderer { if !link.url.starts_with("file://") { self.current_spans.push(Span::styled( format!(" ({})", link.url), - Style::default().fg(Color::DarkGray), + self.styles.blockquote, )); } } @@ -528,6 +620,143 @@ impl MarkdownRenderer { mod tests { use super::*; + fn parse_markdown_line_with_theme(text: &str, theme: &crate::themes::Theme) -> Line<'static> { + render_with_styles(text, None, MarkdownStyles::for_theme(theme)) + .lines + .into_iter() + .next() + .unwrap_or_default() + } + + #[test] + fn inline_code_uses_readable_theme_ink_without_terminal_dimming() { + for name in [ + "light", + "green", + "pink", + "blue", + "green-dark", + "pink-dark", + "blue-dark", + ] { + let theme = crate::themes::load_theme(name).unwrap(); + let line = parse_markdown_line_with_theme("Run `cargo test`.", &theme); + let code = line + .spans + .iter() + .find(|span| span.content.trim_matches('`') == "cargo test") + .unwrap(); + assert_eq!(code.style.fg, theme.get_color("md_code")); + assert!(!code.style.add_modifier.contains(Modifier::DIM)); + } + let dark = crate::themes::dark_theme(); + let code = parse_markdown_line_with_theme("`cargo test`", &dark); + assert_eq!(code.spans[0].style.fg, dark.get_color("md_code")); + assert!(!code.spans[0].style.add_modifier.contains(Modifier::DIM)); + } + + #[test] + fn markdown_links_respect_distinct_theme_link_colors() { + let mut custom = crate::themes::light_theme(); + custom.colors.md_heading = "#ff0000".into(); + custom.colors.md_link = "#00ff00".into(); + assert_ne!(custom.get_color("md_link"), custom.get_color("md_heading")); + for theme in [crate::themes::light_theme(), custom] { + let line = parse_markdown_line_with_theme("See [guide](https://example.com).", &theme); + let link = line + .spans + .iter() + .find(|span| span.content == "guide") + .unwrap(); + assert_eq!(link.style.fg, theme.get_color("md_link")); + assert!(link.style.add_modifier.contains(Modifier::UNDERLINED)); + } + let line = parse_markdown_line_with_theme( + "[guide](https://example.com)", + &crate::themes::dark_theme(), + ); + assert_eq!( + line.spans[0].style.fg, + crate::themes::dark_theme().get_color("md_link") + ); + } + + fn visible(text: &Text<'_>) -> Vec { + text.lines + .iter() + .map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect()) + .collect() + } + + #[test] + fn headings_and_code_use_visual_hierarchy_without_source_fences() { + let text = render_markdown("## Result\n\n```rust\nfn main() {}\n```"); + let lines = visible(&text); + assert_eq!(lines[0], "Result"); + assert!( + text.lines[0].spans[0] + .style + .add_modifier + .contains(Modifier::BOLD) + ); + assert!(lines.iter().any(|line| line == "rust")); + assert!(lines.iter().any(|line| line == "fn main() {}")); + assert!(!lines.iter().any(|line| line.contains("```"))); + } + + #[test] + fn tables_preserve_cells_at_wide_and_narrow_widths() { + let input = "| Item | Status |\n| --- | --- |\n| 世界 | Ready |\n| Build | Passed |"; + for width in [12, 24, 60, 100] { + let text = render_markdown_with_width(input, Some(width)); + assert!(text.lines.iter().all(|line| line.width() <= width)); + let rendered = visible(&text).join("\n"); + for cell in ["Item", "Status", "世界", "Ready", "Build", "Passed"] { + assert!( + rendered.contains(cell), + "missing {cell} at {width}: {rendered}" + ); + } + } + } + + #[test] + fn nested_and_loose_lists_keep_their_markers() { + let text = render_markdown("- First\n\n- Second\n - Nested"); + let lines = visible(&text); + assert!(lines.iter().any(|line| line == "• First")); + assert!(lines.iter().any(|line| line == "• Second")); + assert!(lines.iter().any(|line| line == " • Nested")); + } + + #[test] + fn incomplete_streamed_code_remains_visible() { + let text = render_markdown("```rust\nlet answer = 42;"); + assert!(visible(&text).iter().any(|line| line == "let answer = 42;")); + } + + #[test] + fn inline_code_removes_only_markdown_delimiters() { + let text = render_markdown("Run `cargo test`, then `` echo `date` ``."); + assert_eq!(visible(&text), ["Run cargo test, then echo `date`."]); + let link = render_markdown("[`src/main.rs`](file:///tmp/src/main.rs)"); + assert_eq!( + crate::hyperlink::strip_hyperlinks(&visible(&link).join("\n")), + "src/main.rs" + ); + } + + #[test] + fn horizontal_rules_fit_the_viewport_and_use_theme_ink() { + let styles = MarkdownStyles::for_theme(&crate::themes::light_theme()); + for width in [1, 12, 24, 60] { + let text = render_with_styles("---", Some(width), styles.clone()); + assert_eq!(text.lines.len(), 1); + assert_eq!(text.lines[0].width(), width.min(40)); + assert_eq!(text.lines[0].spans[0].style, styles.blockquote); + } + } + #[test] fn renders_plain_text() { let text = render_markdown("Hello, world!"); diff --git a/packages/tui-rs/src/onboarding_checks.rs b/packages/tui-rs/src/onboarding_checks.rs index 8f959aab8..c6699b442 100644 --- a/packages/tui-rs/src/onboarding_checks.rs +++ b/packages/tui-rs/src/onboarding_checks.rs @@ -422,6 +422,8 @@ mod tests { summary: "untrusted detail must not reach onboarding".to_owned(), detail: Some("sensitive detail".to_owned()), live: true, + duration_ms: None, + remediation: None, } } diff --git a/packages/tui-rs/src/print_mode.rs b/packages/tui-rs/src/print_mode.rs index 9474fa233..8ea51ff4e 100644 --- a/packages/tui-rs/src/print_mode.rs +++ b/packages/tui-rs/src/print_mode.rs @@ -37,6 +37,16 @@ pub struct PrintModeOptions { pub sandbox_policy: Option, /// Reject tool calls that would require interactive approval. pub fail_on_approval: bool, + /// Restrict this run to these tools before applying narrower policy ceilings. + pub only_tools: Vec, + /// Add tools to the run default without widening an administrator ceiling. + pub add_tools: Vec, + /// Remove tools from the effective run catalog. + pub remove_tools: Vec, + /// Searchable run metadata emitted in structured output and telemetry. + pub tags: Vec, + /// Correlation key for related CI, release, or batch runs. + pub log_group_id: Option, } fn approval_denied( @@ -160,6 +170,97 @@ fn allowed_tools_from_env() -> Result>> { Ok(Some(tools)) } +fn normalized_tool_names(values: &[String]) -> HashSet { + values + .iter() + .flat_map(|value| value.split([',', ' '])) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_lowercase) + .collect() +} + +fn available_tool_names() -> HashSet { + crate::tools::ToolRegistry::new() + .tools() + .map(|definition| definition.tool.name.to_ascii_lowercase()) + .collect() +} + +fn effective_run_tools( + environment_ceiling: Option>, + only: &[String], + add: &[String], + remove: &[String], +) -> Result>> { + let available = available_tool_names(); + let only = normalized_tool_names(only); + let add = normalized_tool_names(add); + let remove = normalized_tool_names(remove); + let requested = only + .iter() + .chain(add.iter()) + .chain(remove.iter()) + .filter(|tool| !available.contains(*tool)) + .cloned() + .collect::>(); + if !requested.is_empty() { + bail!( + "Unknown print-run tool(s): {}. Run `deixic-code exec --list-tools`.", + requested.join(", ") + ); + } + + let has_cli_selection = !only.is_empty() || !add.is_empty() || !remove.is_empty(); + if !has_cli_selection { + return Ok(environment_ceiling); + } + + let ceiling = environment_ceiling.unwrap_or_else(|| available.clone()); + let mut effective = if only.is_empty() { + ceiling.clone() + } else { + only.intersection(&ceiling).cloned().collect() + }; + // Additions are still bounded by the authoritative environment ceiling. + effective.extend(add.intersection(&ceiling).cloned()); + effective.retain(|tool| !remove.contains(tool)); + Ok(Some(effective)) +} + +pub(crate) fn print_available_tools(json: bool) -> Result<()> { + let registry = crate::tools::ToolRegistry::new(); + let mut tools = registry + .tools() + .map(|definition| { + serde_json::json!({ + "name": definition.tool.name, + "requiresApproval": definition.requires_approval, + }) + }) + .collect::>(); + tools.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str())); + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({"tools": tools}))? + ); + } else { + for tool in tools { + println!( + "{:<28} {}", + tool["name"].as_str().unwrap_or_default(), + if tool["requiresApproval"].as_bool().unwrap_or(false) { + "approval may be required" + } else { + "available" + } + ); + } + } + Ok(()) +} + pub(crate) fn canonical_workspace_path(workspace: &Path, input: &str) -> Result { let canonical_workspace = dunce::canonicalize(workspace) .with_context(|| format!("canonicalize workspace {}", workspace.display()))?; @@ -302,9 +403,13 @@ fn validate_print_local_model( /// Establish per-run managed request lineage before the first print prompt. /// This identity does not claim ownership of a persisted transcript or spills. -pub(crate) async fn start_print_prompt(agent: &NativeAgent, prompt: String) -> Result<()> { +pub(crate) async fn start_print_prompt( + agent: &NativeAgent, + run_id: &str, + prompt: String, +) -> Result<()> { agent - .set_session_context(Some(uuid::Uuid::new_v4().to_string()), "print", false) + .set_session_context(Some(run_id.to_owned()), "print", false) .context("Failed to establish print session context")?; agent.send_ready(); agent @@ -382,6 +487,12 @@ pub async fn run_print_mode(options: PrintModeOptions) -> Result { crate::local_models::replace_discovered_models(0, &[discovered], Some(&model)); } let mut limits = PrintModeLimits::from_env(&model)?; + limits.allowed_tools = effective_run_tools( + limits.allowed_tools.take(), + &options.only_tools, + &options.add_tools, + &options.remove_tools, + )?; if let Some(tools) = specialist.as_ref().and_then(|p| p.tools.as_ref()) { limits.allowed_tools = Some(specialist_tool_ceiling(limits.allowed_tools.take(), tools)); } @@ -475,7 +586,32 @@ pub async fn run_print_mode(options: PrintModeOptions) -> Result { None => ToolExecutor::with_credential_vault(&cwd, credential_vault.clone()).unattended(), }; - start_print_prompt(&agent, options.prompt).await?; + let run_id = uuid::Uuid::new_v4().to_string(); + let mut effective_tools = limits + .allowed_tools + .as_ref() + .map(|tools| tools.iter().cloned().collect::>()) + .unwrap_or_else(|| available_tool_names().into_iter().collect()); + effective_tools.sort(); + if options.json { + println!( + "{}", + serde_json::json!({ + "type": "run_started", + "run_id": run_id, + "tags": options.tags, + "log_group_id": options.log_group_id, + "effective_tools": effective_tools, + }) + ); + } + tracing::info!( + run_id = %run_id, + log_group_id = options.log_group_id.as_deref().unwrap_or(""), + tags = %serde_json::Value::Array(options.tags.clone()), + "print run started" + ); + start_print_prompt(&agent, &run_id, options.prompt).await?; let mut exit_code = 0i32; let mut assistant_buf = String::new(); @@ -699,6 +835,9 @@ pub async fn run_print_mode(options: PrintModeOptions) -> Result { let done = serde_json::json!({ "type": "done", "status": "ok", + "run_id": run_id, + "tags": options.tags, + "log_group_id": options.log_group_id, }); println!("{done}"); } @@ -839,6 +978,11 @@ pub async fn run_print_prompts( }, sandbox_policy: None, fail_on_approval: false, + only_tools: Vec::new(), + add_tools: Vec::new(), + remove_tools: Vec::new(), + tags: Vec::new(), + log_group_id: None, }) .await?; if result != 0 { @@ -965,6 +1109,22 @@ pub fn resolve_output_path(path: &str) -> PathBuf { #[cfg(test)] mod tests { + #[test] + fn run_tool_selection_cannot_widen_environment_ceiling() { + let ceiling = Some(["read".to_string()].into_iter().collect()); + let effective = super::effective_run_tools(ceiling, &[], &["read,bash".to_string()], &[]) + .expect("valid tool selection") + .expect("explicit selection"); + assert_eq!(effective, ["read".to_string()].into_iter().collect()); + } + + #[test] + fn run_tool_selection_rejects_unknown_names() { + let error = super::effective_run_tools(None, &["missing-tool-9f64".to_string()], &[], &[]) + .expect_err("unknown tools must fail closed"); + assert!(error.to_string().contains("missing-tool-9f64")); + } + #[test] fn specialist_tools_only_narrow_the_run_ceiling() { let allowed = Some(["read".to_string()].into_iter().collect()); @@ -984,6 +1144,11 @@ mod tests { output_schema: None, sandbox_policy: None, fail_on_approval: true, + only_tools: Vec::new(), + add_tools: Vec::new(), + remove_tools: Vec::new(), + tags: Vec::new(), + log_group_id: None, }) .await; assert!(result.unwrap_err().to_string().contains("authorized scope")); @@ -1117,6 +1282,11 @@ mod tests { output_schema: None, sandbox_policy: None, fail_on_approval: false, + only_tools: Vec::new(), + add_tools: Vec::new(), + remove_tools: Vec::new(), + tags: Vec::new(), + log_group_id: None, }; assert!(!opts.json); assert_eq!(opts.prompt, "hi"); diff --git a/packages/tui-rs/src/setup_cli.rs b/packages/tui-rs/src/setup_cli.rs index 1c0cce24d..99bf84106 100644 --- a/packages/tui-rs/src/setup_cli.rs +++ b/packages/tui-rs/src/setup_cli.rs @@ -319,6 +319,8 @@ mod tests { summary: summary.to_owned(), detail: detail.map(str::to_owned), live: false, + duration_ms: None, + remediation: None, } } diff --git a/packages/tui-rs/src/syntax.rs b/packages/tui-rs/src/syntax.rs index 930919bba..a22f263a8 100644 --- a/packages/tui-rs/src/syntax.rs +++ b/packages/tui-rs/src/syntax.rs @@ -26,9 +26,8 @@ //! //! # Theme //! -//! Uses the "base16-eighties.dark" theme which provides good contrast and readability -//! in terminal environments. The theme uses base16 color palette which maps well to -//! terminal RGB colors. +//! Selects a light or dark syntax palette from the current canvas and adapts +//! foreground colors to the terminal capabilities, including NO_COLOR. //! //! # Example //! @@ -40,7 +39,7 @@ //! // `lines` contains styled ratatui Line instances ready for rendering //! ``` -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use syntect::easy::HighlightLines; use syntect::highlighting::{FontStyle, ThemeSet}; @@ -121,8 +120,8 @@ pub fn highlight_code(code: &str, language: Option<&str>) -> Vec> }) .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text()); - // Use base16-eighties as a dark theme that works well in terminals - let theme = &THEME_SET.themes["base16-eighties.dark"]; + let ui_theme = crate::themes::current_theme(); + let theme = &THEME_SET.themes[syntax_theme_name(&ui_theme)]; let mut highlighter = HighlightLines::new(syntax, theme); let mut result = Vec::new(); @@ -135,8 +134,11 @@ pub fn highlight_code(code: &str, language: Option<&str>) -> Vec> let spans: Vec> = ranges .iter() .map(|(style, text)| { - let fg = - Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b); + let fg = crate::palette::best_color( + style.foreground.r, + style.foreground.g, + style.foreground.b, + ); let modifier = font_style_to_modifier(style.font_style); Span::styled( (*text).to_string(), @@ -156,6 +158,22 @@ pub fn highlight_code(code: &str, language: Option<&str>) -> Vec> result } +// Choose contrast from the source palette, before terminal color quantization. +fn syntax_theme_name(theme: &crate::themes::Theme) -> &'static str { + let rgb = theme.colors.assistant_message_bg.trim_start_matches('#'); + let light = rgb + .get(..6) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .is_some_and(|rgb| { + crate::color_utils::is_light(((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8)) + }); + if light { + "InspiredGitHub" + } else { + "base16-eighties.dark" + } +} + /// Check if syntax highlighting is available for a language. /// /// Returns `true` if syntect has a syntax definition for the given language identifier. @@ -194,6 +212,18 @@ pub fn supported_languages() -> Vec<&'static str> { mod tests { use super::*; + #[test] + fn syntax_contrast_follows_palette_before_terminal_quantization() { + assert_eq!( + syntax_theme_name(&crate::themes::light_theme()), + "InspiredGitHub" + ); + assert_eq!( + syntax_theme_name(&crate::themes::dark_theme()), + "base16-eighties.dark" + ); + } + #[test] fn test_highlight_rust() { let code = "fn main() {\n println!(\"Hello, world!\");\n}"; diff --git a/packages/tui-rs/src/themes/mod.rs b/packages/tui-rs/src/themes/mod.rs index 683e499dd..e6463d47a 100644 --- a/packages/tui-rs/src/themes/mod.rs +++ b/packages/tui-rs/src/themes/mod.rs @@ -206,9 +206,9 @@ impl Default for ThemeColors { tool_success_bg: "#14532d20".to_string(), tool_error_bg: "#7f1d1d20".to_string(), - md_heading: hex(controls.focus), + md_heading: hex(controls.text), md_link: hex(controls.focus), - md_code: "#fde047".to_string(), + md_code: "#c2b6e8".to_string(), md_code_block: hex(controls.surface), md_code_block_border: hex(controls.border), md_quote: hex(controls.muted), diff --git a/packages/tui-rs/src/tooltips.rs b/packages/tui-rs/src/tooltips.rs index 6fc6b841a..1284430ca 100644 --- a/packages/tui-rs/src/tooltips.rs +++ b/packages/tui-rs/src/tooltips.rs @@ -2,7 +2,7 @@ //! //! Displays helpful tips to users, loaded from tooltips.txt -use rand::Rng; +use rand::RngExt; const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); diff --git a/packages/tui-rs/tests/pty_e2e.rs b/packages/tui-rs/tests/pty_e2e.rs index ef5be04ef..3607a3d2f 100644 --- a/packages/tui-rs/tests/pty_e2e.rs +++ b/packages/tui-rs/tests/pty_e2e.rs @@ -40,6 +40,7 @@ const TURN_TIMEOUT: Duration = Duration::from_secs(30); /// One scripted streaming response: the raw SSE body to serve for a single /// `POST /v1/chat/completions` request. struct ScriptedTurn { + status: &'static str, sse_body: String, } @@ -69,6 +70,7 @@ fn chunk(delta: serde_json::Value, finish_reason: Option<&str>) -> serde_json::V /// A streamed assistant text answer. fn text_turn(text: &str) -> ScriptedTurn { ScriptedTurn { + status: "200 OK", sse_body: sse_body(&[ chunk( serde_json::json!({"role": "assistant", "content": text}), @@ -82,6 +84,7 @@ fn text_turn(text: &str) -> ScriptedTurn { /// A streamed assistant tool call (Chat Completions `tool_calls` deltas). fn tool_call_turn(name: &str, arguments: &serde_json::Value) -> ScriptedTurn { ScriptedTurn { + status: "200 OK", sse_body: sse_body(&[ chunk( serde_json::json!({ @@ -166,7 +169,7 @@ impl MockOpenAiServer { state.script.pop_front() }; let (status, content_type, payload) = match next { - Some(turn) => ("200 OK", "text/event-stream", turn.sse_body), + Some(turn) => (turn.status, "text/event-stream", turn.sse_body), None => ( "500 Internal Server Error", "text/plain", @@ -1072,6 +1075,57 @@ fn pty_confirmed_rewind_preserves_earlier_turn_and_persists_child_lineage() { /// the app's position reads. static PTY_TEST_SERIAL: Mutex<()> = Mutex::new(()); +fn bad_gateway_turn() -> ScriptedTurn { + ScriptedTurn { + status: "502 Bad Gateway", + sse_body: "error code: 502".into(), + } +} + +#[test] +fn pty_502_after_tool_recovers_without_reexecuting_tool() { + let _serial = PTY_TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + let mock = MockOpenAiServer::start(vec![ + tool_call_turn( + "bash", + &serde_json::json!({"command": "printf x >> once.txt"}), + ), + bad_gateway_turn(), + text_turn("PTY_502_RECOVERED"), + ]); + let workdir = tempfile::tempdir().expect("temp workdir"); + let mut session = PtySession::spawn(&mock, workdir.path(), "write one x to once.txt"); + session.wait_for_text("Action Approval Required", READY_TIMEOUT); + session.send_bytes_until(b"y", "PTY_502_RECOVERED", TURN_TIMEOUT); + assert_eq!(mock.request_count(), 3); + assert_eq!( + std::fs::read_to_string(workdir.path().join("once.txt")).unwrap(), + "x" + ); + assert!(session.child.try_wait().unwrap().is_none()); + session.shutdown(); +} + +#[test] +fn pty_exhausted_502_keeps_session_alive_for_next_prompt() { + let _serial = PTY_TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + let mock = MockOpenAiServer::start(vec![ + bad_gateway_turn(), + bad_gateway_turn(), + bad_gateway_turn(), + text_turn("PTY_502_NEXT_PROMPT_OK"), + ]); + let workdir = tempfile::tempdir().expect("temp workdir"); + let mut session = PtySession::spawn(&mock, workdir.path(), "first prompt"); + session.wait_for_text("API error 502 Bad Gateway", READY_TIMEOUT); + assert_eq!(mock.request_count(), 3); + assert!(session.child.try_wait().unwrap().is_none()); + session.submit_prompt("try again"); + session.wait_for_text("PTY_502_NEXT_PROMPT_OK", TURN_TIMEOUT); + assert_eq!(mock.request_count(), 4); + session.shutdown(); +} + /// prompt → streamed answer renders on screen. #[test] fn pty_prompt_streams_answer() { diff --git a/packages/ui-rs/src/lib.rs b/packages/ui-rs/src/lib.rs index f8234c7da..5a1b4dc90 100644 --- a/packages/ui-rs/src/lib.rs +++ b/packages/ui-rs/src/lib.rs @@ -115,6 +115,7 @@ impl<'a> Modal<'a> { margin: 2, block: Block::default() .title(title) + .border_type(ratatui::widgets::BorderType::Rounded) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)) .style(Style::default().bg(Color::Black)), diff --git a/packages/ui-rs/src/localization.rs b/packages/ui-rs/src/localization.rs index 32d294a54..ef092e643 100644 --- a/packages/ui-rs/src/localization.rs +++ b/packages/ui-rs/src/localization.rs @@ -418,6 +418,24 @@ fn interpolate(template: &str, values: &[String]) -> String { #[cfg(test)] mod catalog_tests { use super::*; + + #[test] + fn hook_metrics_translate_labels_and_preserve_runtime_counts() { + let source = "Hook metrics\nPreToolUse: {0}\nPostToolUse: {1}\nOverflow: {2}\nBlocks: {3}\nTotal duration: {4}ms\n"; + let values = ["12", "34", "56", "78", "90"].map(String::from); + for locale in Locale::ALL { + let rendered = locale.format(source, &values); + assert!(rendered.contains("PreToolUse: 12\nPostToolUse: 34\n")); + for count in ["56", "78", "90ms"] { + assert!(rendered.contains(count), "{locale:?}: {rendered}"); + } + assert!(!rendered.contains('{')); + if locale != Locale::English { + assert!(!rendered.starts_with("Hook metrics\n")); + } + } + } + fn fields(text: &str) -> Vec { let mut result = Vec::new(); for tail in text.split('{').skip(1) { diff --git a/packages/ui-rs/src/picker.rs b/packages/ui-rs/src/picker.rs index 0a63592c3..e2a110d0b 100644 --- a/packages/ui-rs/src/picker.rs +++ b/packages/ui-rs/src/picker.rs @@ -73,7 +73,7 @@ impl<'a> Picker<'a> { /// Render inside a surface and return the search rectangle for cursor placement. pub fn render(self, frame: &mut Frame, area: Rect, state: &mut ListState) -> Rect { let chunks = Layout::vertical([ - Constraint::Length(3), + Constraint::Length(2), Constraint::Min(0), Constraint::Length(u16::from(self.help.is_some())), ]) @@ -81,7 +81,7 @@ impl<'a> Picker<'a> { let mut search = SearchField::new(self.query, self.placeholder) .block( Block::default() - .borders(Borders::ALL) + .borders(Borders::BOTTOM) .border_style(Style::default().fg(self.theme.border)), ) .theme(self.theme); @@ -191,9 +191,9 @@ mod tests { }) .unwrap(); let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(0, 3)].symbol(), "›"); + assert_eq!(buffer[(0, 2)].symbol(), "›"); for x in 2..13 { - let cell = &buffer[(x, 3)]; + let cell = &buffer[(x, 2)]; assert_eq!(cell.fg, palette.focus); assert_eq!(cell.bg, palette.surface); assert_ne!(cell.fg, cell.bg); diff --git a/packages/ui-rs/src/translations.rs b/packages/ui-rs/src/translations.rs index 1a9af10ed..f1823be6b 100644 --- a/packages/ui-rs/src/translations.rs +++ b/packages/ui-rs/src/translations.rs @@ -11175,6 +11175,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "Hook 管理行动", ], ), + ( + "Hook metrics\nPreToolUse: {0}\nPostToolUse: {1}\nOverflow: {2}\nBlocks: {3}\nTotal duration: {4}ms\n", + [ + "Métricas de hooks\nPreToolUse: {0}\nPostToolUse: {1}\nDesbordamientos: {2}\nBloqueos: {3}\nDuración total: {4}ms\n", + "Métriques des hooks\nPreToolUse: {0}\nPostToolUse: {1}\nDépassements: {2}\nBlocages: {3}\nDurée totale: {4}ms\n", + "Hook-Metriken\nPreToolUse: {0}\nPostToolUse: {1}\nÜberläufe: {2}\nBlockierungen: {3}\nGesamtdauer: {4}ms\n", + "フックのメトリクス\nPreToolUse: {0}\nPostToolUse: {1}\nオーバーフロー: {2}\nブロック: {3}\n合計時間: {4}ms\n", + "훅 지표\nPreToolUse: {0}\nPostToolUse: {1}\n오버플로: {2}\n차단: {3}\n총 소요 시간: {4}ms\n", + "钩子指标\nPreToolUse: {0}\nPostToolUse: {1}\n溢出: {2}\n阻止: {3}\n总耗时: {4}ms\n", + ], + ), ( "Hook status (native summary)", [ diff --git a/packages/ui-rs/tests/action_picker.rs b/packages/ui-rs/tests/action_picker.rs index 54723962a..88dd7eda8 100644 --- a/packages/ui-rs/tests/action_picker.rs +++ b/packages/ui-rs/tests/action_picker.rs @@ -154,7 +154,7 @@ fn long_unicode_search_keeps_the_edited_suffix_and_cursor_visible() { "the edited end of the query must be visible" ); let end = terminal.get_cursor_position().unwrap(); - assert_eq!(end.y, 1); + assert_eq!(end.y, 0); // Use a query that fits to distinguish caret movement from viewport scrolling. picker.open(); picker.insert_str("日本TAIL"); diff --git a/scripts/capture-tui-suite.py b/scripts/capture-tui-suite.py index 159048c55..c22c2327c 100644 --- a/scripts/capture-tui-suite.py +++ b/scripts/capture-tui-suite.py @@ -33,6 +33,7 @@ "idle", "typing", "conversation", + "markdown", "command-palette", "streaming", "approval", @@ -59,6 +60,7 @@ for name in ( "idle", "conversation", + "markdown", "command-palette", "long-conversation", "details", @@ -105,6 +107,9 @@ def appearance_scenes(binary): "name": identifier, "steps": [ {"wait": "(?s)Dex Code.*GPT-6 Astra.*release-planner"}, + {"text": "/dex standard"}, + {"key": "Enter"}, + {"wait": "Dex: standard"}, {"text": "/dex " + identifier}, {"key": "Enter"}, {"wait": re.escape(label) + ".*saved"}, diff --git a/scripts/check-ci-concurrency.test.mjs b/scripts/check-ci-concurrency.test.mjs index d473c9924..ebad131b2 100644 --- a/scripts/check-ci-concurrency.test.mjs +++ b/scripts/check-ci-concurrency.test.mjs @@ -30,6 +30,14 @@ const a2aTmuxSmoke = readFileSync( new URL("./smoke-maestro-a2a-tmux.sh", import.meta.url), "utf8", ); +const interactiveTuiSmoke = readFileSync( + new URL("./smoke-tui-interactive.sh", import.meta.url), + "utf8", +); +const setupRust = readFileSync( + new URL("../.github/actions/setup-rust/action.yml", import.meta.url), + "utf8", +); test("maestro-ci concurrency is per ref and cancels in-progress PR runs", () => { assert.match( @@ -226,6 +234,39 @@ test("CI caps rustc codegen units and compile jobs", () => { assert.match(workflow, /RUST_TOOLCHAIN: "1\.95\.0"/); }); +test("Rust setup uses Google-backed sccache and forbids the GitHub backend", () => { + const configure = + setupRust + .split(" - name: Configure Google-backed sccache")[1] + ?.split("\n - name: Cache Cargo")[0] ?? ""; + const cargoCache = + setupRust + .split(" - name: Cache Cargo")[1] + ?.split("\n - name: Configure Cargo network resilience")[0] ?? ""; + assert.match(configure, /uses: \.\/\.github\/actions\/setup-sccache/); + assert.match(configure, /version: v0\.17\.0/); + assert.match(configure, /backend: auto/); + assert.match(configure, /rust-toolchain: \$\{\{ inputs\.toolchain \}\}/); + assert.match(configure, /allow-gha-fallback: "false"/); + assert.doesNotMatch(configure, /SCCACHE_GHA_ENABLED/); + assert.match(cargoCache, /cargo-inputs-v3/); + assert.match(cargoCache, /cache-targets: "false"/); +}); + +test("interactive TUI smoke isolates state and skips first-run onboarding", () => { + assert.match(interactiveTuiSmoke, /SMOKE_HOME="\$\(mktemp -d/); + assert.match(interactiveTuiSmoke, /trap cleanup EXIT/); + assert.match(interactiveTuiSmoke, /\{"onboardingSeen":true\}/); + assert.match(interactiveTuiSmoke, /export MAESTRO_HOME=\$QUOTED_SMOKE_HOME/); + assert.match(interactiveTuiSmoke, /export MAESTRO_TELEMETRY=0/); + assert.match(interactiveTuiSmoke, /export MAESTRO_AUTO_UPDATE=0/); + assert.ok( + interactiveTuiSmoke.indexOf("export MAESTRO_HOME=$QUOTED_SMOKE_HOME") < + interactiveTuiSmoke.indexOf("'$BIN' --provider openai"), + "isolated preferences must be active before the TUI starts", + ); +}); + test("network and long-running operations are bounded", () => { assert.equal((workflow.match(/5m npm ci --ignore-scripts/g) ?? []).length, 7); assert.match(workflow, /npm_config_fetch_retries: "1"/); diff --git a/scripts/check-scheduled-workflow-health.mjs b/scripts/check-scheduled-workflow-health.mjs index 85860ee4d..30adabb9d 100644 --- a/scripts/check-scheduled-workflow-health.mjs +++ b/scripts/check-scheduled-workflow-health.mjs @@ -235,23 +235,36 @@ function resolveWorkflow(repo, workflow) { * default branch", which means the same thing for a schedule and for a push. */ const IGNORED_RUN_EVENTS = new Set(["workflow_dispatch"]); -const RUN_PAGE_OVERFETCH = 3; -const MAX_RUN_PAGE = 100; +const RUN_PAGE_SIZE = 100; +const MAX_RUN_PAGES = 10; -function recentRuns(repo, workflowId, count, branch) { - // Over-fetch: the dispatch/incomplete filtering below happens client-side, - // so a page of exactly `count` can shrink to fewer than `count` runs. - const perPage = Math.min(count * RUN_PAGE_OVERFETCH, MAX_RUN_PAGE); - const data = ghJson([ - `repos/${repo}/actions/workflows/${workflowId}/runs?branch=${encodeURIComponent(branch)}&exclude_pull_requests=true&per_page=${perPage}`, - ]); - return (Array.isArray(data.workflow_runs) ? data.workflow_runs : []) +export function selectAutomaticRuns(pageRuns, count) { + return (Array.isArray(pageRuns) ? pageRuns : []) .filter( (run) => run.status === "completed" && !IGNORED_RUN_EVENTS.has(run.event), ) .slice(0, count); } +function recentRuns(repo, workflowId, count, branch) { + // Manual dispatches share the run list with automatic runs, so one page + // can be entirely dispatches even when the workflow fires on its own + // cadence. Keep paging (bounded) until `count` automatic runs are + // collected: a burst of manual re-runs must not manufacture a blind spot. + const collected = []; + for (let page = 1; page <= MAX_RUN_PAGES; page += 1) { + const data = ghJson([ + `repos/${repo}/actions/workflows/${workflowId}/runs?branch=${encodeURIComponent(branch)}&exclude_pull_requests=true&per_page=${RUN_PAGE_SIZE}&page=${page}`, + ]); + const pageRuns = Array.isArray(data.workflow_runs) ? data.workflow_runs : []; + collected.push(...selectAutomaticRuns(pageRuns, count - collected.length)); + if (collected.length >= count || pageRuns.length < RUN_PAGE_SIZE) { + break; + } + } + return collected.slice(0, count); +} + function countConsecutiveFailures(runs) { let count = 0; for (const run of runs) { diff --git a/scripts/smoke-tui-interactive.sh b/scripts/smoke-tui-interactive.sh index a2a098b63..697b37015 100755 --- a/scripts/smoke-tui-interactive.sh +++ b/scripts/smoke-tui-interactive.sh @@ -29,8 +29,21 @@ if ! command -v tmux >/dev/null 2>&1; then fi SESSION="maestro-tui-smoke-$$" -tmux kill-session -t "$SESSION" 2>/dev/null || true +SMOKE_HOME="$(mktemp -d "${TMPDIR:-/tmp}/maestro-tui-smoke.XXXXXX")" +# Invoked by the EXIT trap below. +# shellcheck disable=SC2329 +cleanup() { + tmux kill-session -t "$SESSION" 2>/dev/null || true + rm -rf -- "$SMOKE_HOME" +} +trap cleanup EXIT + +printf '%s\n' '{"onboardingSeen":true}' > "$SMOKE_HOME/ui.json" +printf -v QUOTED_SMOKE_HOME '%q' "$SMOKE_HOME" tmux new-session -d -s "$SESSION" -x 100 -y 30 -c "$ROOT" +tmux send-keys -t "$SESSION" "export MAESTRO_HOME=$QUOTED_SMOKE_HOME" Enter +tmux send-keys -t "$SESSION" "export MAESTRO_TELEMETRY=0" Enter +tmux send-keys -t "$SESSION" "export MAESTRO_AUTO_UPDATE=0" Enter tmux send-keys -t "$SESSION" "export MAESTRO_NO_SESSION=1" Enter # Use a dummy key so startup does not block on credential resolution for paint. tmux send-keys -t "$SESSION" "export OPENAI_API_KEY=sk-test-smoke" Enter @@ -40,7 +53,6 @@ PANE="$(tmux capture-pane -t "$SESSION" -p -S -80 || true)" if ! printf '%s' "$PANE" | grep -E -q 'Type a message|deixic|◉|gpt-4|approvals:|trust:|sandbox:'; then echo "TUI did not paint expected chrome:" >&2 printf '%s\n' "$PANE" >&2 - tmux kill-session -t "$SESSION" 2>/dev/null || true exit 1 fi tmux send-keys -t "$SESSION" C-c diff --git a/scripts/tui_capture_fixture.py b/scripts/tui_capture_fixture.py index 2a71cd7d1..162666794 100644 --- a/scripts/tui_capture_fixture.py +++ b/scripts/tui_capture_fixture.py @@ -126,6 +126,16 @@ def do_POST(self): delta["content"] = ( "I couldn’t read missing-checklist.md because it does not exist. Check the filename or choose README.md to continue." ) + if fixture.scene == "markdown": + delta["content"] = ( + "## Release checklist\n\n" + "The project tracks **owners**, review steps, and shipped work.\n\n" + "| Step | Owner | Status |\n| --- | --- | --- |\n" + "| Review | Maya | Ready |\n| Publish | Alex | Pending |\n\n" + "```rust\nfn ready(approved: bool) -> bool {\n approved\n}\n```\n\n" + "- Review the changes.\n- Publish after approval.\n\n" + "Final row visible." + ) finish = "stop" elif fixture.scene in {"summary-review", "summary-save"} and fixture.turn == 3: request = json.loads(request_body) diff --git a/test/fixtures/tui-capture/dex-appearance.json b/test/fixtures/tui-capture/dex-appearance.json index 39fe2438a..246ee16b0 100644 --- a/test/fixtures/tui-capture/dex-appearance.json +++ b/test/fixtures/tui-capture/dex-appearance.json @@ -4,6 +4,15 @@ { "wait": "(?s)Dex.*GPT-6 Astra.*release-planner" }, + { + "text": "/dex standard" + }, + { + "key": "Enter" + }, + { + "wait": "Dex: standard" + }, { "text": "/dex accessory-beanie" }, diff --git a/test/fixtures/tui-capture/dex-pet.json b/test/fixtures/tui-capture/dex-pet.json index ef907a942..3d28a9f49 100644 --- a/test/fixtures/tui-capture/dex-pet.json +++ b/test/fixtures/tui-capture/dex-pet.json @@ -4,6 +4,15 @@ { "wait": "(?s)Dex.*GPT-6 Astra.*release-planner" }, + { + "text": "/dex standard" + }, + { + "key": "Enter" + }, + { + "wait": "Dex: standard" + }, { "text": "/dex pet" }, diff --git a/test/fixtures/tui-capture/dex-preview-cancel.json b/test/fixtures/tui-capture/dex-preview-cancel.json index da59ba94f..6e6ec28de 100644 --- a/test/fixtures/tui-capture/dex-preview-cancel.json +++ b/test/fixtures/tui-capture/dex-preview-cancel.json @@ -4,6 +4,15 @@ { "wait": "(?s)Dex.*GPT-6 Astra.*release-planner" }, + { + "text": "/dex standard" + }, + { + "key": "Enter" + }, + { + "wait": "Dex: standard" + }, { "text": "/dex accessory-crown" }, diff --git a/test/fixtures/tui-capture/dex-preview-save.json b/test/fixtures/tui-capture/dex-preview-save.json index 772d5fa35..6df4719d6 100644 --- a/test/fixtures/tui-capture/dex-preview-save.json +++ b/test/fixtures/tui-capture/dex-preview-save.json @@ -4,6 +4,15 @@ { "wait": "(?s)Dex.*GPT-6 Astra.*release-planner" }, + { + "text": "/dex standard" + }, + { + "key": "Enter" + }, + { + "wait": "Dex: standard" + }, { "text": "/dex accessory-crown" }, diff --git a/test/fixtures/tui-capture/dex-reduced-motion.json b/test/fixtures/tui-capture/dex-reduced-motion.json index 2432ae3cd..6812f90fb 100644 --- a/test/fixtures/tui-capture/dex-reduced-motion.json +++ b/test/fixtures/tui-capture/dex-reduced-motion.json @@ -4,6 +4,15 @@ { "wait": "(?s)Dex.*GPT-6 Astra.*release-planner" }, + { + "text": "/dex standard" + }, + { + "key": "Enter" + }, + { + "wait": "Dex: standard" + }, { "text": "/dex motion-off" }, diff --git a/test/fixtures/tui-capture/markdown.json b/test/fixtures/tui-capture/markdown.json new file mode 100644 index 000000000..bbca1e66e --- /dev/null +++ b/test/fixtures/tui-capture/markdown.json @@ -0,0 +1,23 @@ +{ + "name": "markdown", + "steps": [ + { + "wait": "(?s)Dex.*GPT-6 Astra.*release-planner" + }, + { + "text": "Read the README and show the checklist, a table, and a code sample." + }, + { + "key": "Enter" + }, + { + "wait": "Final row visible" + }, + { + "absent": "Working|Thinking" + }, + { + "wait": "Dex \u00b7 finished" + } + ] +} diff --git a/test/fixtures/tui-capture/pet-reactions.json b/test/fixtures/tui-capture/pet-reactions.json index 4604436da..3c2616e13 100644 --- a/test/fixtures/tui-capture/pet-reactions.json +++ b/test/fixtures/tui-capture/pet-reactions.json @@ -4,6 +4,15 @@ { "wait": "(?s)Dex Code.*GPT-6 Astra.*release-planner" }, + { + "text": "/dex standard" + }, + { + "key": "Enter" + }, + { + "wait": "Dex: standard" + }, { "text": "/dex pet" },