diff --git a/.github/workflows/pipboy.yml b/.github/workflows/pipboy.yml new file mode 100644 index 0000000000..9d8ba65a9a --- /dev/null +++ b/.github/workflows/pipboy.yml @@ -0,0 +1,357 @@ +# Pip-Boy: what a change does to the packages we ship, in one PR comment. +# +# Every job runs on `pull_request` or `push`. Only `report` gets a write token, +# it builds nothing, and it comments only on same-repo PRs. Pushes to main +# store baselines that later PRs compare against. Spec and tooling: +# scripts/pipboy. +name: pipboy + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: pipboy-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +permissions: {} + +jobs: + changes: + name: changes + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + base: ${{ steps.changes.outputs.base }} + base_history: ${{ steps.changes.outputs.base_history }} + footprint: ${{ steps.changes.outputs.footprint }} + tool: ${{ steps.changes.outputs.tool }} + rust: ${{ steps.changes.outputs.rust }} + native: ${{ steps.changes.outputs.native }} + native_matrix: ${{ steps.changes.outputs.native_matrix }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false + - id: changes + env: + EVENT: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE: ${{ github.event.before }} + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + # The sidecar binaries the publish workflow ships, on the same runners. + # `linux` builds on any Rust change; the rest build on platform-specific + # changes or the `ci: native-all` label, which is read on the next push. + NATIVE_ALL: >- + [ + {"platform":"linux-x64-gnu","target":"x86_64-unknown-linux-gnu","runner":"ubuntu-latest","dockerfile":"linux-gnu","clang":"","tier":"linux"}, + {"platform":"linux-arm64-gnu","target":"aarch64-unknown-linux-gnu","runner":"ubuntu-24.04-arm","dockerfile":"linux-gnu","clang":"","tier":"all"}, + {"platform":"darwin-x64","target":"x86_64-apple-darwin","runner":"ubuntu-latest","dockerfile":"darwin","clang":"x86_64-apple-darwin20.4","tier":"all"}, + {"platform":"darwin-arm64","target":"aarch64-apple-darwin","runner":"ubuntu-latest","dockerfile":"darwin","clang":"aarch64-apple-darwin20.4","tier":"all"} + ] + run: | + RUST='(\.rs$|(^|/)Cargo\.(toml|lock)$|^deny\.toml$|^rust-toolchain\.toml$|^docker/build/|^scripts/pipboy/|^\.github/workflows/pipboy\.yml$)' + PLATFORM_SENSITIVE='((^|/)Cargo\.(toml|lock)$|(^|/)build\.rs$|^docker/build/|^rust-toolchain\.toml$)' + matrix() { jq -c --arg tier "$1" 'map(select($tier == "all" or .tier == $tier))' <<< "$NATIVE_ALL"; } + + if [ "$EVENT" != "pull_request" ]; then + # Pushes to main record baselines. Natives build only when Rust changed, + # and then for every platform. Hygiene stores no baseline, so it skips main. + echo "footprint=true" >> "$GITHUB_OUTPUT" + echo "tool=true" >> "$GITHUB_OUTPUT" + echo "rust=false" >> "$GITHUB_OUTPUT" + native=none + if ! files="$(git diff --name-only "$PUSH_BEFORE" HEAD 2>/dev/null)" || grep -qE "$RUST" <<< "$files"; then + native=all + fi + echo "native=$native" >> "$GITHUB_OUTPUT" + echo "native_matrix=$(matrix "$native")" >> "$GITHUB_OUTPUT" + exit 0 + fi + base="$(git merge-base "$PR_BASE_SHA" HEAD)" + echo "base=$base" >> "$GITHUB_OUTPUT" + echo "base_history=$(git rev-list --first-parent -n 200 "$base" | paste -sd, -)" >> "$GITHUB_OUTPUT" + files="$(git diff --name-only "$base" HEAD)" + footprint=false + if grep -qE '^(packages/|pnpm-(lock|workspace)\.yaml|scripts/pipboy/|scripts/publish/|\.github/workflows/pipboy\.yml)' <<< "$files"; then + footprint=true + fi + echo "footprint=$footprint" >> "$GITHUB_OUTPUT" + tool=false + if grep -qE '^(scripts/pipboy/|scripts/publish/|\.github/workflows/pipboy\.yml)' <<< "$files"; then + tool=true + fi + echo "tool=$tool" >> "$GITHUB_OUTPUT" + rust=false + native=none + if grep -qE "$RUST" <<< "$files"; then + rust=true + native=linux + if grep -qE "$PLATFORM_SENSITIVE" <<< "$files" \ + || git diff -U0 "$base" HEAD -- '*.rs' | grep -qE '^\+.*(cfg\(target_|target_os|target_arch)' \ + || jq -e 'index("ci: native-all")' <<< "$LABELS" > /dev/null; then + native=all + fi + fi + echo "rust=$rust" >> "$GITHUB_OUTPUT" + echo "native=$native" >> "$GITHUB_OUTPUT" + echo "native_matrix=$(matrix "$native")" >> "$GITHUB_OUTPUT" + + self-test: + name: self-test + needs: changes + if: needs.changes.outputs.tool == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: pnpm install --frozen-lockfile --filter 'pipboy...' + - run: pnpm --filter pipboy check-types + # Replays the September 2026 registry drift against a local npm registry. + - run: pnpm --filter pipboy test + + footprint: + name: footprint + needs: changes + if: needs.changes.outputs.footprint == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + actions: read # download the base tarballs from a main run + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + + - name: Pack this commit + run: pnpm --silent --filter pipboy pipboy pack --out "$RUNNER_TEMP/pr" + + - name: Store tarballs as the baseline for later PRs + if: github.event_name == 'push' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: footprint-tarballs + path: ${{ runner.temp }}/pr + retention-days: 90 + + - name: Fetch the base tarballs from main + id: baseline + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + BASE_SHA: ${{ needs.changes.outputs.base }} + run: pnpm --silent --filter pipboy pipboy baseline --sha "$BASE_SHA" --artifact footprint-tarballs --out "$RUNNER_TEMP/base" + + # Main has no baseline for this commit (older than this workflow, or + # expired), so pack the merge base here with this commit's tool. + - name: Pack the base + if: github.event_name == 'pull_request' && steps.baseline.outputs.found != 'true' + env: + BASE_SHA: ${{ needs.changes.outputs.base }} + run: | + git worktree add "$RUNNER_TEMP/base-src" "$BASE_SHA" + pnpm --dir "$RUNNER_TEMP/base-src" install --frozen-lockfile + pnpm --silent --filter pipboy pipboy pack --root "$RUNNER_TEMP/base-src" --out "$RUNNER_TEMP/base" + + - name: Measure + env: + EVENT: ${{ github.event_name }} + run: | + mkdir -p "$RUNNER_TEMP/reports" + base=() + if [ "$EVENT" = "pull_request" ]; then base=(--base "$RUNNER_TEMP/base"); fi + pnpm --silent --filter pipboy pipboy footprint --pr "$RUNNER_TEMP/pr" "${base[@]}" --out "$RUNNER_TEMP/reports/footprint.json" + + - if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: report-footprint + path: ${{ runner.temp }}/reports/footprint.json + if-no-files-found: ignore + retention-days: 30 + + hygiene: + name: hygiene + needs: changes + if: needs.changes.outputs.rust == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: pnpm install --frozen-lockfile --filter 'pipboy...' + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + with: + tool: cargo-deny@0.20.2,cargo-shear@1.14.0 + # cargo deny reads every crate's manifest; caching the registry skips the downloads. + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + key: cargo-registry-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-registry- + - name: Check out the base + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ needs.changes.outputs.base }} + run: git worktree add "$RUNNER_TEMP/base-src" "$BASE_SHA" + - name: Diff findings + env: + EVENT: ${{ github.event_name }} + run: | + mkdir -p "$RUNNER_TEMP/reports" + base=() + if [ "$EVENT" = "pull_request" ]; then base=(--base-root "$RUNNER_TEMP/base-src"); fi + pnpm --silent --filter pipboy pipboy hygiene "${base[@]}" --out "$RUNNER_TEMP/reports/hygiene.json" + - if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: report-hygiene + path: ${{ runner.temp }}/reports/hygiene.json + if-no-files-found: ignore + retention-days: 30 + + native: + name: native · ${{ matrix.platform }} + needs: changes + if: needs.changes.outputs.native != 'none' + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.changes.outputs.native_matrix) }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + # The publish workflow's Dockerfile and build arguments in release mode, so + # the size is what ships. Toolchain versions are read from publish.yaml. + - name: Build with the publish Dockerfile + env: + PLATFORM: ${{ matrix.platform }} + TARGET: ${{ matrix.target }} + DOCKERFILE: ${{ matrix.dockerfile }} + CLANG: ${{ matrix.clang }} + run: | + publish_env() { sed -n "s/^ $1: \"\(.*\)\"$/\1/p" .github/workflows/publish.yaml; } + args=(--build-arg "TARGET=$TARGET" --build-arg BUILD_PROFILE=release --build-arg "CACHE_PLATFORM=$PLATFORM" + --build-arg "RUST_TOOLCHAIN=$(publish_env RUST_TOOLCHAIN)") + if [ "$DOCKERFILE" = darwin ]; then + args+=(--build-arg "CLANG=$CLANG") + else + args+=(--build-arg "LINUX_GNU_LLVM_VERSION=$(publish_env LINUX_GNU_LLVM_VERSION)" + --build-arg "LINUX_GNU_SYSROOT_TAG=$(publish_env LINUX_GNU_SYSROOT_TAG)") + fi + docker buildx build -f "docker/build/$DOCKERFILE.Dockerfile" "${args[@]}" -t pipboy-native --load . + cid="$(docker create pipboy-native)" + docker cp "$cid:/artifacts/agentos-native-sidecar" "$RUNNER_TEMP/agentos-native-sidecar" + docker rm "$cid" + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + # No pnpm cache: this job runs fork code and doesn't need one. + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + - run: pnpm install --frozen-lockfile --filter 'pipboy...' + - name: Measure + env: + PLATFORM: ${{ matrix.platform }} + run: | + mkdir -p "$RUNNER_TEMP/reports" + pnpm --silent --filter pipboy pipboy native --target agentos-native-sidecar --platform "$PLATFORM" \ + --artifact "$RUNNER_TEMP/agentos-native-sidecar" --out "$RUNNER_TEMP/reports/native-agentos-native-sidecar-$PLATFORM.json" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: report-native-agentos-native-sidecar-${{ matrix.platform }} + path: ${{ runner.temp }}/reports/native-agentos-native-sidecar-${{ matrix.platform }}.json + retention-days: 90 + + report: + name: report + needs: [changes, footprint, hygiene, native] + # Not `always()`: a run cancelled by a newer push must not post its partial results. + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read # job timings, report artifacts, and native baselines from main + pull-requests: write # the one Pip-Boy comment + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: pnpm install --frozen-lockfile --filter 'pipboy...' + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: report-* + path: ${{ runner.temp }}/reports + merge-multiple: true + - name: Write the comment and run summary + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + # Empty for pushes and fork PRs: they get the run summary only. + PR_NUMBER: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.number || '' }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + BASE_HISTORY: ${{ needs.changes.outputs.base_history }} + # Each check job's result, so a failed job is reported as failed, not skipped. + JOB_RESULTS: ${{ toJSON(needs) }} + run: | + pnpm --silent --filter pipboy pipboy report --reports "$RUNNER_TEMP/reports" \ + --base-history "$BASE_HISTORY" + + zizmor: + name: zizmor + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + - run: uvx zizmor@1.31.1 --offline --min-severity low .github/workflows/pipboy.yml diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000000..a3a5ed6b0a --- /dev/null +++ b/deny.toml @@ -0,0 +1,34 @@ +# cargo-deny configuration. The Pip-Boy hygiene check runs it on every PR +# that changes Rust and fails only on findings the PR adds. + +[licenses] +# The same license policy as rivet-dev/rivet. +allow = [ + "Apache-2.0", + "0BSD", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MIT", + "MPL-2.0", + "CC0-1.0", + "Unicode-3.0", + "Zlib", + "EPL-2.0", + "Unlicense", + # Boost Software License, not Business Source License (ryu). + "BSL-1.0", + # Mozilla's CA root list (webpki-roots, webpki-root-certs). + "CDLA-Permissive-2.0", +] +unused-allowed-license = "allow" + +[[licenses.exceptions]] +name = "ring" +allow = ["OpenSSL"] + +# ring 0.16 ships its license only as a file cargo-deny can't detect. +[[licenses.clarify]] +name = "ring" +expression = "ISC AND MIT AND OpenSSL" +license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dea68f65c1..0dcb65afb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1239,6 +1239,34 @@ importers: specifier: ^2.1.9 version: 2.1.9(@types/node@22.19.15)(lightningcss@1.33.0) + scripts/pipboy: + dependencies: + '@effect/platform-node': + specifier: 4.0.5 + version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.3.0(@opentelemetry/api@1.9.0)) + effect: + specifier: 4.0.5 + version: 4.0.5 + esbuild: + specifier: ^0.25.9 + version: 0.25.12 + publish: + specifier: workspace:* + version: link:../publish + tsx: + specifier: ^4.20.5 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + devDependencies: + '@types/node': + specifier: ^22.13.9 + version: 22.19.15 + vitest: + specifier: ^3.1.1 + version: 3.2.7(@types/node@22.19.15)(lightningcss@1.33.0) + scripts/publish: dependencies: commander: @@ -2507,12 +2535,31 @@ packages: '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@effect/platform-node-shared@4.0.5': + resolution: {integrity: sha512-a8rCdEkfZBGe9C6bsIjxVZuRp73svIyK5IHwHdDAVsvZXjrbkxusfI+rZ8WlX31RP1mKizwI/E72GQTNfdMWHw==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.5 + + '@effect/platform-node@4.0.5': + resolution: {integrity: sha512-v4KD5fWvEUa4QmWFpZFbBsRhmfnTP6ETCME2r9ifyXVDPjhfpxpTZl41prjgQFMqZBHdWWCl7YC5ImbXv5ui5g==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.5 + redis: '>=5.0.0 <7.0.0' + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} @@ -2525,6 +2572,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.4': resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} @@ -2537,6 +2590,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.4': resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} @@ -2549,6 +2608,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.4': resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} @@ -2561,6 +2626,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.4': resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} @@ -2573,6 +2644,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.4': resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} @@ -2585,6 +2662,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.4': resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} @@ -2597,6 +2680,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.4': resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} @@ -2609,6 +2698,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.4': resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} @@ -2621,6 +2716,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.4': resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} @@ -2633,6 +2734,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.4': resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} @@ -2645,6 +2752,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.4': resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} @@ -2657,6 +2770,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.4': resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} @@ -2669,6 +2788,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.4': resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} @@ -2681,6 +2806,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.4': resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} @@ -2693,6 +2824,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.4': resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} @@ -2705,12 +2842,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.4': resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.4': resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} @@ -2723,12 +2872,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.4': resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.4': resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} @@ -2741,12 +2902,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.4': resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.4': resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} @@ -2759,6 +2932,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.4': resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} @@ -2771,6 +2950,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.4': resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} @@ -2783,6 +2968,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.4': resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} @@ -2795,6 +2986,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.4': resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} @@ -2961,6 +3158,42 @@ packages: engines: {node: '>=18'} hasBin: true + '@redis/bloom@6.3.0': + resolution: {integrity: sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.3.0 + + '@redis/client@6.3.0': + resolution: {integrity: sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + + '@redis/json@6.3.0': + resolution: {integrity: sha512-yt6vRfPBXtW/3qm53cF81TSQpLMjWdW4/2E9WQ2GgbvsKkuCKCFQIcMiKlil1ZMnezK+IQuS795xKU5psbaegw==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.3.0 + + '@redis/search@6.3.0': + resolution: {integrity: sha512-RPXAZmKjZZG7Tm7m1w9keFdXzbL91PiSHQBfQRe9xwW40jNsTRbG0FwhIz4QlHPU4gKDAsCy8IQHj4yZl4H9rw==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.3.0 + + '@redis/time-series@6.3.0': + resolution: {integrity: sha512-+fJOB8mN1z5WRakCJNh5lVdz1KqvHGSOistsKeitD24QsCtPwyRQXWC0iq6T/i/ubLE/YcguZqPm8F598MKk4A==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.3.0 + '@rollup/rollup-android-arm-eabi@4.60.1': resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} cpu: [arm] @@ -3411,6 +3644,12 @@ packages: '@types/argparse@1.0.38': resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -3438,6 +3677,9 @@ packages: '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -3459,6 +3701,9 @@ packages: '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + '@vitest/mocker@2.1.9': resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} peerDependencies: @@ -3470,21 +3715,47 @@ packages: vite: optional: true + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + '@vitest/runner@2.1.9': resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + '@vitest/snapshot@2.1.9': resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + '@vitest/spy@2.1.9': resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + '@workflow/serde@4.1.0-beta.2': resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} @@ -4104,6 +4375,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@4.0.5: + resolution: {integrity: sha512-BY4/28NlpOd99yzmV741xbVGmp+1U7tks4oB7F/Png64bTa5tm5eCA6W0RiXsnuLDDoJpjfVSo4Dnq4BZb/89Q==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -4153,6 +4427,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.4: resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} @@ -4662,6 +4941,9 @@ packages: js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -5420,6 +5702,10 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redis@6.3.0: + resolution: {integrity: sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw==} + engines: {node: '>= 20.0.0'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -5695,6 +5981,9 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.4.1: resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} @@ -5776,10 +6065,18 @@ packages: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + tinyspy@3.0.2: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + tldts-core@7.0.28: resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} @@ -5901,6 +6198,10 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + undici@8.12.3: + resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==} + engines: {node: '>=22.19.0'} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -5955,6 +6256,11 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -6011,6 +6317,34 @@ packages: jsdom: optional: true + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vm-browserify@1.1.2: resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} @@ -6096,6 +6430,18 @@ packages: utf-8-validate: optional: true + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xdg-app-paths@5.1.0: resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} engines: {node: '>=6'} @@ -7022,150 +7368,247 @@ snapshots: '@cfworker/json-schema@4.1.1': {} + '@effect/platform-node-shared@4.0.5(bufferutil@4.1.0)(effect@4.0.5)': + dependencies: + '@types/ws': 8.18.2 + effect: 4.0.5 + ws: 8.22.0(bufferutil@4.1.0) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.3.0(@opentelemetry/api@1.9.0))': + dependencies: + '@effect/platform-node-shared': 4.0.5(bufferutil@4.1.0)(effect@4.0.5) + effect: 4.0.5 + redis: 6.3.0(@opentelemetry/api@1.9.0) + undici: 8.12.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.27.4': optional: true '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.27.4': optional: true '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.4': optional: true '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.4': optional: true '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.4': optional: true '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.4': optional: true '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.4': optional: true '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.4': optional: true '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.4': optional: true '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.4': optional: true '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.4': optional: true '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.4': optional: true '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.4': optional: true '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.4': optional: true '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.4': optional: true '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.4': optional: true '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.4': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.4': optional: true '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.4': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.4': optional: true '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.4': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.4': optional: true '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.4': optional: true '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.4': optional: true '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.4': optional: true '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.4': optional: true @@ -7412,6 +7855,26 @@ snapshots: - supports-color optional: true + '@redis/bloom@6.3.0(@redis/client@6.3.0(@opentelemetry/api@1.9.0))': + dependencies: + '@redis/client': 6.3.0(@opentelemetry/api@1.9.0) + + '@redis/client@6.3.0(@opentelemetry/api@1.9.0)': + optionalDependencies: + '@opentelemetry/api': 1.9.0 + + '@redis/json@6.3.0(@redis/client@6.3.0(@opentelemetry/api@1.9.0))': + dependencies: + '@redis/client': 6.3.0(@opentelemetry/api@1.9.0) + + '@redis/search@6.3.0(@redis/client@6.3.0(@opentelemetry/api@1.9.0))': + dependencies: + '@redis/client': 6.3.0(@opentelemetry/api@1.9.0) + + '@redis/time-series@6.3.0(@redis/client@6.3.0(@opentelemetry/api@1.9.0))': + dependencies: + '@redis/client': 6.3.0(@opentelemetry/api@1.9.0) + '@rollup/rollup-android-arm-eabi@4.60.1': optional: true @@ -7936,6 +8399,13 @@ snapshots: '@types/argparse@1.0.38': optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/node-fetch@2.6.13': @@ -7965,6 +8435,10 @@ snapshots: '@types/uuid@10.0.0': {} + '@types/ws@8.18.2': + dependencies: + '@types/node': 22.19.15 + '@types/yauzl@2.10.3': dependencies: '@types/node': 22.19.15 @@ -8002,6 +8476,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.15)(lightningcss@1.33.0))': dependencies: '@vitest/spy': 2.1.9 @@ -8010,31 +8492,65 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@22.19.15)(lightningcss@1.33.0) + '@vitest/mocker@3.2.7(vite@5.4.21(@types/node@22.19.15)(lightningcss@1.33.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.15)(lightningcss@1.33.0) + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/runner@2.1.9': dependencies: '@vitest/utils': 2.1.9 pathe: 1.1.2 + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/snapshot@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 magic-string: 0.30.21 pathe: 1.1.2 + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@2.1.9': dependencies: tinyspy: 3.0.2 + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + '@vitest/utils@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 loupe: 3.2.1 tinyrainbow: 1.2.0 + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@workflow/serde@4.1.0-beta.2': optional: true @@ -8734,6 +9250,8 @@ snapshots: ee-first@1.1.1: {} + effect@4.0.5: {} + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -8803,6 +9321,35 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -9418,6 +9965,8 @@ snapshots: dependencies: base64-js: 1.5.1 + js-tokens@9.0.1: {} + json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 @@ -10186,6 +10735,17 @@ snapshots: real-require@0.2.0: {} + redis@6.3.0(@opentelemetry/api@1.9.0): + dependencies: + '@redis/bloom': 6.3.0(@redis/client@6.3.0(@opentelemetry/api@1.9.0)) + '@redis/client': 6.3.0(@opentelemetry/api@1.9.0) + '@redis/json': 6.3.0(@redis/client@6.3.0(@opentelemetry/api@1.9.0)) + '@redis/search': 6.3.0(@redis/client@6.3.0(@opentelemetry/api@1.9.0)) + '@redis/time-series': 6.3.0(@redis/client@6.3.0(@opentelemetry/api@1.9.0)) + transitivePeerDependencies: + - '@node-rs/xxhash' + - '@opentelemetry/api' + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -10496,6 +11056,10 @@ snapshots: strip-json-comments@5.0.3: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + strnum@2.4.1: dependencies: anynum: 1.0.1 @@ -10619,8 +11183,12 @@ snapshots: tinyrainbow@1.2.0: {} + tinyrainbow@2.0.0: {} + tinyspy@3.0.2: {} + tinyspy@4.0.6: {} + tldts-core@7.0.28: {} tldts@7.0.28: @@ -10744,6 +11312,8 @@ snapshots: undici@7.28.0: optional: true + undici@8.12.3: {} + universalify@0.1.2: optional: true @@ -10803,6 +11373,24 @@ snapshots: - supports-color - terser + vite-node@3.2.4(@types/node@22.19.15)(lightningcss@1.33.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 5.4.21(@types/node@22.19.15)(lightningcss@1.33.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite@5.4.21(@types/node@22.19.15)(lightningcss@1.33.0): dependencies: esbuild: 0.21.5 @@ -10848,6 +11436,44 @@ snapshots: - supports-color - terser + vitest@3.2.7(@types/node@22.19.15)(lightningcss@1.33.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@5.4.21(@types/node@22.19.15)(lightningcss@1.33.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3(supports-color@8.1.1) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 5.4.21(@types/node@22.19.15)(lightningcss@1.33.0) + vite-node: 3.2.4(@types/node@22.19.15)(lightningcss@1.33.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vm-browserify@1.1.2: {} web-streams-polyfill@3.3.3: {} @@ -10917,6 +11543,10 @@ snapshots: optionalDependencies: bufferutil: 4.1.0 + ws@8.22.0(bufferutil@4.1.0): + optionalDependencies: + bufferutil: 4.1.0 + xdg-app-paths@5.1.0: dependencies: xdg-portable: 7.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b165ad97a0..f509b00d7e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,6 +22,7 @@ packages: # embeddable example is a workspace member that `turbo check-types` covers. - examples/*/* - scripts/publish + - scripts/pipboy ignoredBuiltDependencies: - '@clerk/shared' diff --git a/scripts/pipboy/package.json b/scripts/pipboy/package.json new file mode 100644 index 0000000000..cc74c775eb --- /dev/null +++ b/scripts/pipboy/package.json @@ -0,0 +1,23 @@ +{ + "name": "pipboy", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "pipboy": "tsx src/cli.ts", + "check-types": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@effect/platform-node": "4.0.5", + "effect": "4.0.5", + "esbuild": "^0.25.9", + "publish": "workspace:*", + "tsx": "^4.20.5", + "typescript": "^5.7.3" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "vitest": "^3.1.1" + } +} diff --git a/scripts/pipboy/src/cli.ts b/scripts/pipboy/src/cli.ts new file mode 100644 index 0000000000..81cbb06402 --- /dev/null +++ b/scripts/pipboy/src/cli.ts @@ -0,0 +1,504 @@ +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + NodeHttpClient, + NodeRuntime, + NodeServices, +} from "@effect/platform-node"; +import { + Config, + Console, + Effect, + FileSystem, + Layer, + Option, + Redacted, + Schema, +} from "effect"; +import { Command, Flag } from "effect/cli"; +import { HttpClient, HttpClientRequest } from "effect/http"; +import { compareFootprint } from "./compare/footprint.ts"; +import { compareHygiene } from "./compare/hygiene.ts"; +import { compareNative } from "./compare/native.ts"; +import { REPO_ROOT } from "./config.ts"; +import { run } from "./exec.ts"; +import { inspectEntrypoints } from "./footprint/inspect.ts"; +import { pack, readPackManifest } from "./footprint/pack.ts"; +import * as Registry from "./footprint/registry.ts"; +import { resolveSide } from "./footprint/resolve.ts"; +import * as GitHubApi from "./github/api.ts"; +import { fetchBaseline, fetchNearestBaselines } from "./github/baseline.ts"; +import { upsertComment } from "./github/comment.ts"; +import { readTiming, renderTiming } from "./github/timing.ts"; +import { collectHygiene } from "./hygiene/collect.ts"; +import { measureNative } from "./native/measure.ts"; +import { renderFootprint } from "./render/footprint.ts"; +import { renderHygiene } from "./render/hygiene.ts"; +import { renderNative } from "./render/native.ts"; +import { JobResults, renderCheckSection } from "./render/status.ts"; +import { renderTraceHelp } from "./render/trace.ts"; +import { FootprintReport, type FootprintSide } from "./schema/footprint.ts"; +import { HygieneReport } from "./schema/hygiene.ts"; +import { NativeReport } from "./schema/native.ts"; +import { commitTrace } from "./trace/commit.ts"; + +export class ChecksFailed extends Schema.TaggedError()( + "ChecksFailed", + { + failures: Schema.Array(Schema.String), + }, +) { + override get message() { + return this.failures.join("\n"); + } +} + +/** Reports larger than this are rejected before parsing. Artifacts are untrusted input. */ +const MAX_REPORT_BYTES = 64 * 1024 * 1024; + +const packCommand = Command.make( + "pack", + { + out: Flag.String("out").pipe( + Flag.withDescription("Directory for the tarballs and manifest.json"), + ), + root: Flag.String("root").pipe( + Flag.withDefault(REPO_ROOT), + Flag.withDescription("Checkout to pack, such as the merge base"), + ), + }, + ({ out, root }) => + pack(root, out).pipe( + Effect.flatMap((m) => + Console.log( + `packed ${Object.keys(m.tarballs).length} packages into ${out}`, + ), + ), + Effect.scoped, + ), +).pipe( + Command.withDescription( + "Build and pack publishable workspace packages the way npm publish would", + ), +); + +const footprintCommand = Command.make( + "footprint", + { + pr: Flag.String("pr").pipe( + Flag.withDescription("Tarball directory of this change, from `pack`"), + ), + base: Flag.String("base").pipe( + Flag.optional, + Flag.withDescription("Tarball directory of the merge base"), + ), + out: Flag.String("out").pipe( + Flag.withDescription("Where to write the JSON report"), + ), + }, + Effect.fn("Cli.footprint")(function* ({ pr, base, out }) { + const fs = yield* FileSystem.FileSystem; + // `@latest` is resolved for exactly the packages this change publishes. + const roots = Object.keys((yield* readPackManifest(pr)).tarballs); + + const tarballSide = (side: "pr" | "base", dir: string) => + Effect.all([ + resolveSide({ side, tarballDir: dir }), + inspectEntrypoints(dir), + ]).pipe( + Effect.map( + ([resolved, entrypoints]): FootprintSide => ({ + ...resolved, + entrypoints, + }), + ), + ); + const sides = yield* Effect.scoped( + Effect.all( + [ + resolveSide({ side: "latest", roots }), + tarballSide("pr", pr), + ...(Option.isSome(base) ? [tarballSide("base", base.value)] : []), + ], + { concurrency: "unbounded" }, + ), + ); + + const report: FootprintReport = { schema: 1, check: "footprint", sides }; + yield* fs.writeFileString(out, JSON.stringify(report)); + const comparison = compareFootprint(report); + yield* Console.log(renderFootprint(comparison)); + if (comparison.failures.length > 0) + return yield* new ChecksFailed({ failures: comparison.failures }); + }, Effect.provide(Registry.layer)), +).pipe( + Command.withDescription( + "Measure what users install, resolved fresh from the registry without downloading", + ), +); + +const hygieneCommand = Command.make( + "hygiene", + { + root: Flag.String("root").pipe( + Flag.withDefault(REPO_ROOT), + Flag.withDescription("Checkout of this change"), + ), + base: Flag.String("base-root").pipe( + Flag.optional, + Flag.withDescription("Checkout of the merge base"), + ), + out: Flag.String("out").pipe( + Flag.withDescription("Where to write the JSON report"), + ), + }, + Effect.fn("Cli.hygiene")(function* ({ root, base, out }) { + const fs = yield* FileSystem.FileSystem; + const [pr, baseFindings] = yield* Effect.all( + [ + collectHygiene(root), + Effect.transposeOption(Option.map(base, collectHygiene)), + ], + { concurrency: "unbounded" }, + ); + const report: HygieneReport = { + schema: 1, + check: "hygiene", + sides: [ + { side: "pr", findings: pr }, + ...(Option.isSome(baseFindings) + ? [{ side: "base" as const, findings: baseFindings.value }] + : []), + ], + }; + yield* fs.writeFileString(out, JSON.stringify(report)); + const comparison = compareHygiene(report); + yield* Console.log(renderHygiene(comparison)); + if (comparison.failures.length > 0) + return yield* new ChecksFailed({ failures: comparison.failures }); + }), +).pipe( + Command.withDescription( + "Diff cargo deny and cargo shear findings between the merge base and this change", + ), +); + +const nativeCommand = Command.make( + "native", + { + target: Flag.String("target").pipe( + Flag.withDescription("agentos-native-sidecar"), + ), + platform: Flag.String("platform"), + artifact: Flag.String("artifact").pipe( + Flag.withDescription("Path to the built binary"), + ), + out: Flag.String("out"), + }, + Effect.fn("Cli.native")(function* ({ target, platform, artifact, out }) { + const fs = yield* FileSystem.FileSystem; + const report = yield* measureNative(target, platform, artifact); + yield* fs.writeFileString(out, JSON.stringify(report)); + yield* Console.log( + `${target} ${platform}: ${report.artifact.bytes} bytes, ${Object.keys(report.artifact.crates).length} crates`, + ); + }, Effect.provide(Registry.layer)), +).pipe( + Command.withDescription( + "Measure one published binary built by the publish Dockerfiles", + ), +); + +const baselineCommand = Command.make( + "baseline", + { + sha: Flag.String("sha").pipe(Flag.withDescription("Merge base commit")), + artifact: Flag.String("artifact"), + out: Flag.String("out"), + }, + Effect.fn("Cli.baseline")(function* ({ sha, artifact, out }) { + const found = yield* fetchBaseline(sha, artifact, out); + yield* setOutput("found", String(Option.isSome(found))); + yield* Console.log( + Option.isSome(found) + ? `baseline from run ${found.value}` + : "no baseline on main for this commit", + ); + }, Effect.provide(GitHubApi.layer)), +).pipe( + Command.withDescription( + "Download a baseline artifact from this workflow's main push run", + ), +); + +const reportCommand = Command.make( + "report", + { + reports: Flag.String("reports").pipe( + Flag.withDescription("Directory holding the checks' JSON reports"), + ), + baseHistory: Flag.String("base-history").pipe( + Flag.withDefault(""), + Flag.withDescription( + "Comma-separated merge base ancestry, newest first, for native baselines", + ), + ), + }, + Effect.fn("Cli.report")( + function* ({ reports, baseHistory }) { + const fs = yield* FileSystem.FileSystem; + const currentRun = yield* GitHubApi.CurrentRun; + const env = yield* Config.all({ + // Empty for pushes and fork PRs, which get the run summary instead of a comment. + prNumber: Config.String("PR_NUMBER").pipe(Config.withDefault("")), + headSha: Config.String("HEAD_SHA"), + summary: Config.option(Config.String("GITHUB_STEP_SUMMARY")), + jobResults: Config.String("JOB_RESULTS").pipe(Config.withDefault("{}")), + }); + const results = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString(JobResults), + )(env.jobResults); + const section = ( + heading: string, + job: string, + skipReason: string, + rendered: string | undefined, + ) => + renderCheckSection( + { heading, job, skipReason, rendered }, + results, + currentRun.url, + ); + + const sections: Array = [ + `## 📟 Pip-Boy · \`${env.headSha.slice(0, 7)}\``, + ]; + const footprintPath = join(reports, "footprint.json"); + sections.push( + section( + "### 📦 Packages", + "footprint", + "this PR doesn't change any published package.", + (yield* fs.exists(footprintPath)) + ? renderFootprint( + compareFootprint( + yield* readReport(footprintPath, FootprintReport), + ), + ) + : undefined, + ), + ); + const hygienePath = join(reports, "hygiene.json"); + sections.push( + section( + "### 🧹 Rust dependencies", + "hygiene", + "this PR doesn't change any Rust code or dependencies.", + (yield* fs.exists(hygienePath)) + ? renderHygiene( + compareHygiene(yield* readReport(hygienePath, HygieneReport)), + ) + : undefined, + ), + ); + + // No report artifacts at all (every check skipped) means no reports directory. + const reportFiles = (yield* fs.exists(reports)) + ? yield* fs.readDirectory(reports) + : []; + const nativeFiles = reportFiles + .filter((f) => /^native-.+\.json$/.test(f)) + .sort(); + let nativeRendered: string | undefined; + if (nativeFiles.length > 0) { + const history = baseHistory + .split(",") + .filter((sha) => /^[0-9a-f]{40}$/.test(sha)); + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "pipboy-native-base-", + }); + const pr = yield* Effect.forEach(nativeFiles, (file) => + readReport(join(reports, file), NativeReport), + ); + const baseline = yield* fetchNearestBaselines( + history, + nativeFiles.map((file) => `report-${file.replace(/\.json$/, "")}`), + baseDir, + ); + const base = Option.isSome(baseline) + ? yield* Effect.forEach(baseline.value.found, (artifact) => + readReport( + join(baseDir, `${artifact.replace(/^report-/, "")}.json`), + NativeReport, + ), + ) + : []; + nativeRendered = renderNative( + compareNative(pr, base), + Option.map(baseline, ({ sha }) => ({ + sha, + isMergeBase: sha === history[0], + })), + ); + } + sections.push( + section( + "### 🦀 Binaries", + "native", + "this PR doesn't change any Rust code.", + nativeRendered, + ), + ); + + const timing = yield* readTiming(); + sections.push(renderTiming(timing), renderTraceHelp(env.headSha)); + const body = sections.join("\n\n"); + + if (Option.isSome(env.summary)) { + yield* fs.writeFileString(env.summary.value, `${body}\n`, { + flag: "a", + }); + } + if (env.prNumber !== "") { + const result = yield* upsertComment( + Number(env.prNumber), + env.headSha, + fitComment(body, currentRun.url), + ); + yield* Console.log(`comment ${result}`); + } + }, + Effect.scoped, + Effect.provide(GitHubApi.layer), + ), +).pipe( + Command.withDescription( + "Write the PR comment and run summary from the checks' reports", + ), +); + +export class InvalidSha extends Schema.TaggedError()("InvalidSha", { + sha: Schema.String, +}) { + override get message() { + return `--sha must be a full 40-character commit SHA, got \`${this.sha}\``; + } +} + +const traceCommand = Command.make( + "trace", + { + sha: Flag.String("sha").pipe( + Flag.withDescription("Commit to trace (full SHA)"), + ), + repo: Flag.String("repo").pipe(Flag.withDefault("rivet-dev/agentos")), + out: Flag.String("out").pipe( + Flag.optional, + Flag.withDescription("Write the OTLP/JSON trace to this file"), + ), + otlp: Flag.String("otlp").pipe( + Flag.optional, + Flag.withDescription( + "Send the trace to this OTLP/HTTP endpoint, such as motel at http://127.0.0.1:27686", + ), + ), + }, + Effect.fn("Cli.trace")(function* ({ sha, repo, out, otlp }) { + if (!/^[0-9a-f]{40}$/.test(sha)) return yield* new InvalidSha({ sha }); + const fs = yield* FileSystem.FileSystem; + // Locally, fall back to the GitHub CLI's login so no token needs exporting. + const token = + Option.getOrUndefined( + yield* Config.option(Config.Redacted("GITHUB_TOKEN")), + ) ?? Redacted.make((yield* run("gh", ["auth", "token"])).trim()); + const { runs, document } = yield* Effect.scoped(commitTrace(sha)).pipe( + Effect.provide(GitHubApi.layerFor(repo, token)), + ); + const spans = document.resourceSpans[0]?.scopeSpans[0]?.spans.length ?? 0; + + // The default stays out of the checkout, where version control would pick it up. + const path = Option.getOrElse(out, () => + join(tmpdir(), `pipboy-trace-${sha.slice(0, 7)}.json`), + ); + yield* fs.writeFileString(path, JSON.stringify(document)); + yield* Console.log(`${runs} workflow runs, ${spans} spans → ${path}`); + if (Option.isSome(otlp)) { + const http = (yield* HttpClient.HttpClient).pipe( + HttpClient.filterStatusOk, + ); + yield* HttpClientRequest.post( + `${otlp.value.replace(/\/$/, "")}/v1/traces`, + ).pipe( + HttpClientRequest.bodyJson(document), + Effect.flatMap(http.execute), + ); + yield* Console.log(`sent to ${otlp.value}`); + } + }), +).pipe( + Command.withDescription( + "Build one trace of the CI that ran on a commit: CI, Pip-Boy, and releases", + ), +); + +export class ReportTooLarge extends Schema.TaggedError()( + "ReportTooLarge", + { + path: Schema.String, + bytes: Schema.Number, + }, +) { + override get message() { + return `${this.path} is ${this.bytes} bytes, over the ${MAX_REPORT_BYTES} byte limit for reports`; + } +} + +/** Reads a check's JSON report. Reports come from artifacts, so they're size-capped and schema-decoded. */ +const readReport = Effect.fn("Cli.readReport")(function* < + S extends Schema.Constraint, +>(path: string, schema: S) { + const fs = yield* FileSystem.FileSystem; + const bytes = Number((yield* fs.stat(path)).size); + if (bytes > MAX_REPORT_BYTES) + return yield* new ReportTooLarge({ path, bytes }); + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))( + yield* fs.readFileString(path), + ); +}); + +/** GitHub rejects comments over 65,536 characters; the run summary always has the full report. */ +const MAX_COMMENT = 60_000; +function fitComment(body: string, runUrl: string): string { + if (body.length <= MAX_COMMENT) return body; + return `${body.slice(0, MAX_COMMENT)}\n\n…truncated. The [run summary](${runUrl}) has the full report.`; +} + +/** Appends `name=value` to the step's outputs. A no-op outside GitHub Actions. */ +const setOutput = Effect.fn("Cli.setOutput")(function* ( + name: string, + value: string, +) { + const path = yield* Config.option(Config.String("GITHUB_OUTPUT")); + if (Option.isNone(path)) return; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path.value, `${name}=${value}\n`, { flag: "a" }); +}); + +const cli = Command.make("pipboy").pipe( + Command.withSubcommands([ + packCommand, + footprintCommand, + hygieneCommand, + nativeCommand, + baselineCommand, + reportCommand, + traceCommand, + ]), +); + +Command.run(cli, { version: "0.0.0" }).pipe( + Effect.provide( + Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici), + ), + NodeRuntime.runMain, +); diff --git a/scripts/pipboy/src/compare/footprint.ts b/scripts/pipboy/src/compare/footprint.ts new file mode 100644 index 0000000000..5e69260b7e --- /dev/null +++ b/scripts/pipboy/src/compare/footprint.ts @@ -0,0 +1,363 @@ +import { + BUDGETS, + GATED_PLATFORM, + PLATFORMS, + type Platform, + THRESHOLDS, + type Threshold, +} from "../config.ts"; +import type { + FootprintReport, + FootprintSide, + PackageNode, + SideName, +} from "../schema/footprint.ts"; + +export type Severity = "ok" | "warn" | "alert" | "fail"; + +export interface Measure { + readonly bytes: number; + readonly count: number; +} + +export interface PackageRef { + readonly id: string; + readonly bytes: number; + /** Package names from the root down to this package. */ + readonly via: ReadonlyArray; +} + +export interface FloatingRange { + readonly declaredBy: string; + readonly dependency: string; + readonly range: string; + readonly resolved: string; + /** Install size of the dependency's subtree on the gated platform. */ + readonly subtreeBytes: number; +} + +export interface EntrypointRow { + readonly subpath: string; + readonly base?: number; + readonly pr?: number; + readonly severity: Severity; + readonly externalsAdded: ReadonlyArray; + readonly externalsRemoved: ReadonlyArray; + readonly exportsAdded: ReadonlyArray; + readonly exportsRemoved: ReadonlyArray; + /** This PR's entrypoint points to a file the package doesn't ship. */ + readonly missing: boolean; +} + +export interface PackageRow { + readonly name: string; + readonly severity: Severity; + /** Install size per platform and side. */ + readonly install: ReadonlyArray< + { readonly platform: string } & Partial> + >; + readonly budget?: { + readonly limit: number; + readonly over: Partial>; + }; + readonly added: ReadonlyArray; + readonly removed: ReadonlyArray; + readonly installScriptsAdded: ReadonlyArray; + readonly largest: ReadonlyArray; + readonly floating: ReadonlyArray; + readonly entrypoints: ReadonlyArray; +} + +export interface FootprintComparison { + /** The side the PR is compared against: `base` on PRs, `latest` on main pushes. */ + readonly reference: SideName; + readonly rows: ReadonlyArray; + /** Packages whose `latest` already exceeds its budget: drift no PR caused. */ + readonly latestOverBudget: ReadonlyArray; + readonly failures: ReadonlyArray; +} + +const TOP = 10; + +export function compareFootprint(report: FootprintReport): FootprintComparison { + const sides = new Map(report.sides.map((side) => [side.side, side])); + const pr = sides.get("pr"); + if (!pr) throw new Error("footprint report has no pr side"); + const reference: SideName = sides.has("base") ? "base" : "latest"; + const ref = sides.get(reference); + + const own = new Set(Object.keys(pr.roots)); + const rows = Object.keys(pr.roots) + .sort() + .map((name) => compareRoot(name, sides, pr, ref, own)); + const failures = rows.flatMap((row) => { + if (row.severity !== "fail" || !row.budget) return []; + const bytes = row.install[0]?.pr?.bytes ?? 0; + return [ + `${row.name} installs ${bytes} bytes on ${GATED_PLATFORM.id}, over its ${row.budget.limit} byte budget`, + ]; + }); + return { + reference, + rows, + latestOverBudget: rows + .filter((row) => row.budget?.over.latest === true) + .map((row) => row.name), + failures, + }; +} + +function compareRoot( + name: string, + sides: ReadonlyMap, + pr: FootprintSide, + ref: FootprintSide | undefined, + own: ReadonlySet, +): PackageRow { + const install = PLATFORMS.map((platform) => { + const measures: Partial> = {}; + for (const [sideName, side] of sides) { + const key = side.roots[name]; + if (key !== undefined) + measures[sideName] = measure(side, closure(side, key, platform)); + } + return { platform: platform.id, ...measures }; + }); + const gated = install[0] ?? { platform: GATED_PLATFORM.id }; + + const limit = BUDGETS[name]; + const over: Partial> = {}; + if (limit !== undefined) { + for (const sideName of sides.keys()) { + const bytes = gated[sideName]?.bytes; + if (bytes !== undefined) over[sideName] = bytes > limit; + } + } + + const prClosure = closure(pr, pr.roots[name] ?? "", GATED_PLATFORM); + const refKey = ref?.roots[name]; + const refClosure = + ref && refKey !== undefined + ? closure(ref, refKey, GATED_PLATFORM) + : new Map>(); + const prIds = byId(pr, prClosure, own); + const refIds = ref + ? byId(ref, refClosure, own) + : new Map(); + + const added = [...prIds.values()] + .filter((p) => !refIds.has(p.id)) + .sort(byBytes); + const removed = [...refIds.values()] + .filter((p) => !prIds.has(p.id)) + .sort(byBytes); + const scripts = (side: FootprintSide, c: Map>) => + new Set( + [...c.keys()].flatMap((key) => + side.nodes[key]?.installScript ? [nodeId(side.nodes[key])] : [], + ), + ); + const refScripts = ref ? scripts(ref, refClosure) : new Set(); + const installScriptsAdded = [...scripts(pr, prClosure)] + .filter((id) => !refScripts.has(id)) + .sort(); + + const entrypoints = compareEntrypoints(name, ref, pr); + const reference = ref?.side; + let severity = worst([ + reference + ? level(gated[reference]?.bytes, gated.pr?.bytes, THRESHOLDS.install) + : "ok", + reference + ? level(gated[reference]?.count, gated.pr?.count, THRESHOLDS.packages) + : "ok", + installScriptsAdded.length > 0 ? "alert" : "ok", + ...entrypoints.map((e) => e.severity), + ]); + // Fail only when the PR itself pushes the package over budget, not when the + // reference already was (registry drift). + if (over.pr === true && (reference === undefined || over[reference] !== true)) + severity = "fail"; + + const flagged = severity !== "ok" || over.pr === true; + return { + name, + severity, + install, + ...(limit !== undefined ? { budget: { limit, over } } : {}), + added: added.slice(0, TOP), + removed: removed.slice(0, TOP), + installScriptsAdded, + largest: flagged + ? [...prIds.values()] + .filter((p) => p.via.length > 1) + .sort(byBytes) + .slice(0, 5) + : [], + floating: flagged ? floatingRanges(pr, prClosure) : [], + entrypoints, + }; +} + +/** Every package installed with `rootKey` on `platform`, mapped to the names on its path from the root. */ +export function closure( + side: FootprintSide, + rootKey: string, + platform: Platform, +): Map> { + const seen = new Map>(); + const root = side.nodes[rootKey]; + if (!root || !installs(root, platform)) return seen; + seen.set(rootKey, [root.name]); + const queue = [rootKey]; + while (queue.length > 0) { + const key = queue.shift() ?? ""; + const via = seen.get(key) ?? []; + for (const dep of side.nodes[key]?.deps ?? []) { + const node = side.nodes[dep]; + if (seen.has(dep) || !node || !installs(node, platform)) continue; + seen.set(dep, [...via, node.name]); + queue.push(dep); + } + } + return seen; +} + +/** npm's `os`/`cpu`/`libc` check. `libc` is only satisfiable on Linux. */ +function installs(node: PackageNode, platform: Platform): boolean { + return ( + matches(node.os, platform.os) && + matches(node.cpu, platform.cpu) && + (node.libc === undefined || + (platform.libc !== undefined && matches(node.libc, platform.libc))) + ); +} + +function matches( + list: ReadonlyArray | undefined, + value: string, +): boolean { + if (!list || list.length === 0) return true; + if (list.includes(`!${value}`)) return false; + const allowed = list.filter((item) => !item.startsWith("!")); + return allowed.length === 0 || allowed.includes(value); +} + +function measure( + side: FootprintSide, + packages: Map>, +): Measure { + return { bytes: sumBytes(side, packages), count: packages.size }; +} + +/** + * Packages by `name@version`. Our own packages are keyed by name alone: the PR + * side packs them under a synthetic version, so a version change isn't news. + */ +function byId( + side: FootprintSide, + packages: Map>, + own: ReadonlySet, +): Map { + const out = new Map(); + for (const [key, via] of packages) { + const node = side.nodes[key]; + if (!node) continue; + const id = own.has(node.name) ? node.name : nodeId(node); + if (!out.has(id)) out.set(id, { id, bytes: node.bytes, via }); + } + return out; +} + +function floatingRanges( + side: FootprintSide, + packages: Map>, +): Array { + const out: Array = []; + for (const key of packages.keys()) { + const node = side.nodes[key]; + if (!node) continue; + for (const [dependency, range] of Object.entries(node.zeroXRanges)) { + const depKey = node.deps.find((d) => side.nodes[d]?.name === dependency); + const dep = depKey === undefined ? undefined : side.nodes[depKey]; + if (!dep || depKey === undefined || dep.version === range.slice(1)) + continue; + out.push({ + declaredBy: nodeId(node), + dependency, + range, + resolved: dep.version, + subtreeBytes: sumBytes(side, closure(side, depKey, GATED_PLATFORM)), + }); + } + } + return out.sort((a, b) => b.subtreeBytes - a.subtreeBytes).slice(0, 5); +} + +/** Entrypoints are only measured for packed sides, so `latest` has nothing to compare against. */ +function compareEntrypoints( + name: string, + ref: FootprintSide | undefined, + pr: FootprintSide, +): Array { + if (ref?.side !== "base") return []; + const before = ref.entrypoints[name] ?? {}; + const after = pr.entrypoints[name] ?? {}; + return [...new Set([...Object.keys(before), ...Object.keys(after)])] + .sort() + .map((subpath) => { + const b = before[subpath]; + const a = after[subpath]; + const diff = ( + x: ReadonlyArray = [], + y: ReadonlyArray = [], + ) => y.filter((v) => !x.includes(v)); + const exportsRemoved = b && a ? diff(a.exports, b.exports) : []; + return { + subpath, + ...(b ? { base: b.bytes } : {}), + ...(a ? { pr: a.bytes } : {}), + severity: worst([ + level(b?.bytes, a?.bytes, THRESHOLDS.bundle), + exportsRemoved.length > 0 ? "alert" : "ok", + // Only a newly broken entrypoint is flagged; an already broken one isn't this PR's doing. + a?.missing === true && b?.missing !== true ? "alert" : "ok", + ]), + externalsAdded: b && a ? diff(b.externals, a.externals) : [], + externalsRemoved: b && a ? diff(a.externals, b.externals) : [], + exportsAdded: b && a ? diff(b.exports, a.exports) : [], + exportsRemoved, + missing: a?.missing === true, + }; + }); +} + +/** Flags a growth that crosses both the relative and the absolute threshold. Shrinking is never flagged. */ +export function level( + before: number | undefined, + after: number | undefined, + t: { warn: Threshold; alert: Threshold }, +): Severity { + if (before === undefined || after === undefined || after <= before) + return "ok"; + const delta = after - before; + const pct = before === 0 ? Number.POSITIVE_INFINITY : (delta / before) * 100; + const crosses = (x: Threshold) => pct >= x.pct && delta >= x.abs; + return crosses(t.alert) ? "alert" : crosses(t.warn) ? "warn" : "ok"; +} + +const ORDER: ReadonlyArray = ["ok", "warn", "alert", "fail"]; +function worst(levels: ReadonlyArray): Severity { + return levels.reduce( + (a, b) => (ORDER.indexOf(b) > ORDER.indexOf(a) ? b : a), + "ok", + ); +} + +const nodeId = (node: PackageNode) => `${node.name}@${node.version}`; +const byBytes = (a: PackageRef, b: PackageRef) => b.bytes - a.bytes; +const sumBytes = (side: FootprintSide, packages: Map>) => + [...packages.keys()].reduce( + (sum, key) => sum + (side.nodes[key]?.bytes ?? 0), + 0, + ); diff --git a/scripts/pipboy/src/compare/hygiene.ts b/scripts/pipboy/src/compare/hygiene.ts new file mode 100644 index 0000000000..ded2eeb126 --- /dev/null +++ b/scripts/pipboy/src/compare/hygiene.ts @@ -0,0 +1,58 @@ +import type { + HygieneCheck, + HygieneFinding, + HygieneReport, +} from "../schema/hygiene.ts"; + +export const HYGIENE_CHECKS: ReadonlyArray = [ + "advisories", + "licenses", + "sources", + "bans", + "unused", +]; + +/** New findings in these checks fail the job. Bans and unused dependencies are reported only. */ +export const BLOCKING: ReadonlySet = new Set([ + "advisories", + "licenses", + "sources", +]); + +export interface HygieneComparison { + readonly hasBase: boolean; + readonly rows: ReadonlyArray<{ + readonly check: HygieneCheck; + readonly added: ReadonlyArray; + readonly fixed: number; + readonly existing: number; + }>; + readonly failures: ReadonlyArray; +} + +/** Findings in the PR that the base doesn't have. Existing debt never fails a PR. */ +export function compareHygiene(report: HygieneReport): HygieneComparison { + const pr = report.sides.find((side) => side.side === "pr")?.findings ?? []; + const base = report.sides.find((side) => side.side === "base")?.findings; + const baseKeys = new Set((base ?? []).map((f) => f.key)); + const prKeys = new Set(pr.map((f) => f.key)); + + const rows = HYGIENE_CHECKS.map((check) => { + const inPr = pr.filter((f) => f.check === check); + const added = + base === undefined ? [] : inPr.filter((f) => !baseKeys.has(f.key)); + return { + check, + added, + fixed: (base ?? []).filter((f) => f.check === check && !prKeys.has(f.key)) + .length, + existing: inPr.length - added.length, + }; + }); + const failures = rows + .filter((row) => BLOCKING.has(row.check)) + .flatMap((row) => + row.added.map((f) => `new ${row.check} finding: ${f.title}`), + ); + return { hasBase: base !== undefined, rows, failures }; +} diff --git a/scripts/pipboy/src/compare/native.ts b/scripts/pipboy/src/compare/native.ts new file mode 100644 index 0000000000..8f46770801 --- /dev/null +++ b/scripts/pipboy/src/compare/native.ts @@ -0,0 +1,60 @@ +import { THRESHOLDS } from "../config.ts"; +import type { NativeReport } from "../schema/native.ts"; +import { level, type Severity } from "./footprint.ts"; + +export interface NativeRow { + readonly target: string; + readonly platform: string; + readonly base?: number; + readonly pr: number; + readonly latest?: number; + readonly severity: Severity; + /** Crates whose symbol bytes changed most, largest change first. */ + readonly crates: ReadonlyArray<{ + readonly name: string; + readonly base: number; + readonly pr: number; + }>; +} + +/** Pairs each PR artifact with its baseline from main, if one was found. */ +export function compareNative( + pr: ReadonlyArray, + base: ReadonlyArray, +): ReadonlyArray { + return pr + .map((report) => { + const a = report.artifact; + const b = base.find( + (r) => + r.artifact.target === a.target && r.artifact.platform === a.platform, + )?.artifact; + const names = new Set([ + ...Object.keys(a.crates), + ...Object.keys(b?.crates ?? {}), + ]); + const crates = [...names] + .map((name) => ({ + name, + base: b?.crates[name] ?? 0, + pr: a.crates[name] ?? 0, + })) + .filter((c) => c.base !== c.pr) + .sort((x, y) => Math.abs(y.pr - y.base) - Math.abs(x.pr - x.base)) + .slice(0, 10); + return { + target: a.target, + platform: a.platform, + ...(b ? { base: b.bytes } : {}), + pr: a.bytes, + ...(report.latestBytes !== undefined + ? { latest: report.latestBytes } + : {}), + severity: level(b?.bytes, a.bytes, THRESHOLDS.binary), + crates: b ? crates : [], + }; + }) + .sort((x, y) => + `${x.target}${x.platform}`.localeCompare(`${y.target}${y.platform}`), + ); +} diff --git a/scripts/pipboy/src/config.ts b/scripts/pipboy/src/config.ts new file mode 100644 index 0000000000..9aa72c37a1 --- /dev/null +++ b/scripts/pipboy/src/config.ts @@ -0,0 +1,95 @@ +import { resolve } from "node:path"; + +export const REPO_ROOT = resolve(import.meta.dirname, "../../.."); + +export interface Platform { + readonly id: string; + readonly os: string; + readonly cpu: string; + /** npm `libc` value. Only set for Linux. */ + readonly libc?: string; +} + +/** Platforms whose install size is reported. The first one is gated by budgets. */ +export const PLATFORMS: ReadonlyArray = [ + { id: "linux-x64-gnu", os: "linux", cpu: "x64", libc: "glibc" }, + { id: "linux-arm64-gnu", os: "linux", cpu: "arm64", libc: "glibc" }, + { id: "darwin-arm64", os: "darwin", cpu: "arm64" }, + { id: "darwin-x64", os: "darwin", cpu: "x64" }, + { id: "win32-x64", os: "win32", cpu: "x64" }, +]; +export const GATED_PLATFORM = PLATFORMS[0]; + +/** + * Packages always resolved from the registry's `latest`, on every side. The + * publish step injects the per-platform sidecar binaries, which a workspace + * pack can't reproduce. The `native` check measures those binaries. + */ +export const REGISTRY_PINNED = ["@rivet-dev/agentos-runtime-sidecar"]; + +export interface AssetSeed { + /** Published package whose `latest` tarball carries the built assets. */ + readonly package: string; + /** Directory inside the tarball holding the assets. */ + readonly from: string; + /** Where the build expects an asset, relative to the checkout, or undefined to skip it. */ + readonly to: (file: string) => string | undefined; +} + +/** + * Build outputs the packages bundle that are too slow to build per PR: WASM + * commands and default software. They're copied from the latest published + * tarballs, so the base and the PR pack the same assets and only code and + * dependency changes show up. + */ +export const ASSET_SEEDS: ReadonlyArray = [ + { + package: "@rivet-dev/agentos-runtime-core", + from: "package/commands/", + to: (file) => `toolchain/target/wasm32-wasip1/release/commands/${file}`, + }, + { + package: "@rivet-dev/agentos-core", + from: "package/dist/default-software/", + to: (file) => (file.endsWith(".aospkg") ? `software/${file.slice(0, -".aospkg".length)}/dist/package.aospkg` : undefined), + }, +]; + +/** + * Fresh install size caps on the gated platform, in bytes: the size measured + * on 2026-10-20 plus 20% headroom. Each includes the 144 MB sidecar binary. + */ +export const BUDGETS: Record = { + "@rivet-dev/agentos-core": 550 * 1024 ** 2, + "@rivet-dev/agentos-posix": 305 * 1024 ** 2, + "@rivet-dev/agentos-runtime-core": 305 * 1024 ** 2, + "@rivet-dev/agentos-sandbox": 600 * 1024 ** 2, +}; + +export interface Threshold { + readonly pct: number; + readonly abs: number; +} + +/** + * A growth is flagged when it crosses both the relative and the absolute + * threshold, so tiny packages don't alert on a few kilobytes. + */ +export const THRESHOLDS = { + install: { + warn: { pct: 5, abs: 1024 ** 2 }, + alert: { pct: 20, abs: 10 * 1024 ** 2 }, + }, + packages: { warn: { pct: 10, abs: 5 }, alert: { pct: 25, abs: 25 } }, + bundle: { + warn: { pct: 5, abs: 5 * 1024 }, + alert: { pct: 20, abs: 25 * 1024 }, + }, + binary: { + warn: { pct: 3, abs: 1024 ** 2 }, + alert: { pct: 10, abs: 5 * 1024 ** 2 }, + }, +} satisfies Record; + +/** Synthetic version for packed workspace packages, so npm can never pick a registry copy instead. */ +export const PACK_VERSION = "0.0.0-pipboy"; diff --git a/scripts/pipboy/src/exec.ts b/scripts/pipboy/src/exec.ts new file mode 100644 index 0000000000..520be20775 --- /dev/null +++ b/scripts/pipboy/src/exec.ts @@ -0,0 +1,96 @@ +import { Effect, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +export class CommandFailed extends Schema.TaggedError()( + "CommandFailed", + { + command: Schema.String, + exitCode: Schema.Number, + stderr: Schema.String, + }, +) { + override get message() { + return `\`${this.command}\` exited with ${this.exitCode}\n${this.stderr}`; + } +} + +export interface RunOptions { + readonly cwd?: string; + readonly env?: Record; +} + +export interface Captured { + readonly command: string; + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +/** Runs a command to completion and returns its output and exit code, whatever the exit code is. */ +export const capture = Effect.fn("Exec.capture")(function* ( + command: string, + args: ReadonlyArray, + options: RunOptions = {}, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make(command, args, { + cwd: options.cwd, + env: options.env, + extendEnv: true, + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + return { + command: [command, ...args].join(" "), + exitCode, + stdout, + stderr, + } satisfies Captured; + }), + ); +}); + +/** A failure that carries the command's own error output. */ +export const commandFailed = (captured: Captured) => + new CommandFailed({ + command: captured.command, + exitCode: captured.exitCode, + stderr: captured.stderr.slice(-4000), + }); + +/** Runs a command to completion and returns its stdout. Fails on a non-zero exit code. */ +export const run = Effect.fn("Exec.run")(function* ( + command: string, + args: ReadonlyArray, + options: RunOptions = {}, +) { + const captured = yield* capture(command, args, options); + if (captured.exitCode !== 0) return yield* commandFailed(captured); + return captured.stdout; +}); + +/** + * Environment for running npm. Package managers inject their own `npm_config_*` + * settings into child processes (pnpm passes `catalog`, `reporter`, and others), + * which change npm's behavior. Those are removed; registry, cache, user config, + * and auth settings are kept. + */ +export function npmEnv(): Record { + const keep = + /^npm_config_(registry|cache|userconfig|globalconfig|_auth.*|.*:_auth.*|\/\/.*)$/i; + return Object.fromEntries( + Object.keys(process.env) + .filter((key) => /^npm_/i.test(key) && !keep.test(key)) + .map((key) => [key, undefined]), + ); +} diff --git a/scripts/pipboy/src/footprint/assets.ts b/scripts/pipboy/src/footprint/assets.ts new file mode 100644 index 0000000000..b52205e7bf --- /dev/null +++ b/scripts/pipboy/src/footprint/assets.ts @@ -0,0 +1,33 @@ +import { dirname, join } from "node:path"; +import { Effect, FileSystem, Schema } from "effect"; +import { ASSET_SEEDS } from "../config.ts"; +import { npmEnv, run } from "../exec.ts"; +import { readTarball } from "./tarball.ts"; + +const PackOutput = Schema.fromJsonString(Schema.Tuple([Schema.Struct({ filename: Schema.String })])); + +/** + * Copies the assets in `ASSET_SEEDS` from the latest published tarballs into + * the checkout at `root`, where the build expects them. Files that already + * exist, such as a local build's output, are left alone. + */ +export const seedAssets = Effect.fn("Footprint.seedAssets")(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pipboy-assets-" }); + let seeded = 0; + for (const seed of ASSET_SEEDS) { + const output = yield* run("npm", ["pack", `${seed.package}@latest`, "--pack-destination", dir, "--json"], { env: npmEnv() }); + const [{ filename }] = yield* Schema.decodeUnknownEffect(PackOutput)(output); + for (const entry of readTarball(yield* fs.readFile(join(dir, filename)))) { + if (!entry.path.startsWith(seed.from)) continue; + const target = seed.to(entry.path.slice(seed.from.length)); + if (target === undefined) continue; + const path = join(root, target); + if (yield* fs.exists(path)) continue; + yield* fs.makeDirectory(dirname(path), { recursive: true }); + yield* fs.writeFile(path, entry.data); + seeded++; + } + } + return seeded; +}); diff --git a/scripts/pipboy/src/footprint/inspect.ts b/scripts/pipboy/src/footprint/inspect.ts new file mode 100644 index 0000000000..70f38252c9 --- /dev/null +++ b/scripts/pipboy/src/footprint/inspect.ts @@ -0,0 +1,264 @@ +import { builtinModules } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { Effect, FileSystem, Schema } from "effect"; +import * as esbuild from "esbuild"; +import ts from "typescript"; +import type { Entrypoint } from "../schema/footprint.ts"; +import { readPackManifest } from "./pack.ts"; +import { readTarball } from "./tarball.ts"; + +export class InspectFailed extends Schema.TaggedError()( + "InspectFailed", + { + packageName: Schema.String, + subpath: Schema.String, + message: Schema.String, + }, +) {} + +type Exports = + | string + | null + | { readonly [key: string]: Exports } + | ReadonlyArray; + +const BUILTINS = new Set( + builtinModules.flatMap((name) => [name, `node:${name}`]), +); + +/** + * Measures every entrypoint of our packed packages: minified own-code bytes, + * third-party imports, and exported names. Our packages are inlined into each + * other's bundles; third-party packages stay external, so no install is needed. + */ +export const inspectEntrypoints = Effect.fn("Footprint.inspectEntrypoints")( + function* (tarballDir: string) { + const fs = yield* FileSystem.FileSystem; + const manifestDir = resolve(tarballDir); + const manifest = yield* readPackManifest(manifestDir); + + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "pipboy-inspect-", + }); + const nodeModules = join(root, "node_modules"); + const files = yield* Effect.forEach( + Object.entries(manifest.tarballs), + ([name, file]) => + fs.readFile(join(manifestDir, file)).pipe( + Effect.map((tarball) => + readTarball(tarball).map((entry) => ({ + path: join( + nodeModules, + name, + entry.path.replace(/^package\//, ""), + ), + data: entry.data, + })), + ), + ), + { concurrency: 8 }, + ).pipe(Effect.map((perPackage) => perPackage.flat())); + yield* Effect.forEach( + new Set(files.map((f) => dirname(f.path))), + (dir) => fs.makeDirectory(dir, { recursive: true }), + { + concurrency: 32, + discard: true, + }, + ); + yield* Effect.forEach(files, (f) => fs.writeFile(f.path, f.data), { + concurrency: 32, + discard: true, + }); + + const own = new Set(Object.keys(manifest.tarballs)); + const entries = yield* Effect.forEach( + own, + (name) => + Effect.gen(function* () { + const pkgDir = join(nodeModules, name); + const pkg = JSON.parse( + yield* fs.readFileString(join(pkgDir, "package.json")), + ); + return entrypointTargets(pkg).flatMap(([subpath, target]) => { + const js = pickCondition( + target, + ["import", "node", "default", "require"], + /\.(m|c)?js$/, + ); + if (js === undefined) return []; + const types = pickCondition( + target, + ["types", "import", "default"], + /\.d\.(m|c)?ts$/, + ); + return [ + { + name, + subpath, + js: join(pkgDir, js), + types: types && join(pkgDir, types), + }, + ]; + }); + }), + { concurrency: 8 }, + ).pipe(Effect.map((perPackage) => perPackage.flat())); + + // esbuild and the TypeScript checker are CPU-bound; a few at a time keeps memory bounded. + const measured = yield* Effect.forEach( + entries, + (e) => + Effect.gen(function* () { + if (!(yield* fs.exists(e.js))) { + const missing: Entrypoint = { bytes: 0, externals: [], exports: [], missing: true }; + return { ...e, m: missing }; + } + const m = yield* inspectEntry(e.name, e.subpath, e.js, e.types, own, nodeModules); + return { ...e, m }; + }), + { concurrency: 4 }, + ); + const result: Record< + string, + Record + > = Object.fromEntries([...own].map((name) => [name, {}])); + for (const { name, subpath, m } of measured) { + const entries = result[name] ?? {}; + entries[subpath] = m; + result[name] = entries; + } + return result; + }, +); + +const inspectEntry = ( + packageName: string, + subpath: string, + jsFile: string, + typesFile: string | undefined, + own: ReadonlySet, + nodeModules: string, +) => + Effect.tryPromise({ + try: async () => { + const externals = new Set(); + const built = await esbuild.build({ + entryPoints: [jsFile], + bundle: true, + minify: true, + platform: "node", + format: "esm", + write: false, + logLevel: "silent", + nodePaths: [nodeModules], + loader: { ".node": "empty", ".wasm": "empty" }, + plugins: [ + { + name: "external-third-party", + setup(build) { + build.onResolve({ filter: /^[^./]/ }, (args) => { + if (BUILTINS.has(args.path)) return { external: true }; + const name = bareName(args.path); + if (own.has(name)) return undefined; + externals.add(name); + return { external: true }; + }); + }, + }, + ], + }); + return { + bytes: built.outputFiles.reduce( + (sum, file) => sum + file.contents.byteLength, + 0, + ), + externals: [...externals].sort(), + exports: typesFile === undefined ? [] : exportedNames(typesFile), + } satisfies Entrypoint; + }, + catch: (cause) => + new InspectFailed({ packageName, subpath, message: String(cause) }), + }); + +function exportedNames(typesFile: string): Array { + const program = ts.createProgram([typesFile], { + noEmit: true, + skipLibCheck: true, + types: [], + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + }); + const source = program.getSourceFile(typesFile); + const checker = program.getTypeChecker(); + const symbol = source && checker.getSymbolAtLocation(source); + if (!symbol) return []; + return checker + .getExportsOfModule(symbol) + .map((s) => s.getName()) + .sort(); +} + +/** `[subpath, target]` pairs from `exports`, or the legacy `main`/`types` fields. */ +function entrypointTargets(pkg: { + exports?: Exports; + main?: string; + module?: string; + types?: string; +}) { + const exports = pkg.exports; + if (exports === undefined || exports === null) { + const main = pkg.module ?? pkg.main; + return main + ? ([[".", { import: main, types: pkg.types ?? null }]] as Array< + [string, Exports] + >) + : []; + } + if (typeof exports === "string" || Array.isArray(exports)) + return [[".", exports]] as Array<[string, Exports]>; + const entries = Object.entries(exports as Record); + if (entries.every(([key]) => !key.startsWith("."))) + return [[".", exports]] as Array<[string, Exports]>; + return entries.filter( + ([key]) => + key.startsWith(".") && !key.includes("*") && key !== "./package.json", + ); +} + +/** Walks conditional exports in condition order and returns the first path matching `pattern`. */ +function pickCondition( + target: Exports, + conditions: ReadonlyArray, + pattern: RegExp, +): string | undefined { + if (typeof target === "string") + return pattern.test(target) ? target : undefined; + if (target === null) return undefined; + if (Array.isArray(target)) { + for (const item of target) { + const found = pickCondition(item, conditions, pattern); + if (found !== undefined) return found; + } + return undefined; + } + const record = target as Record; + for (const condition of conditions) { + if (condition in record) { + const found = pickCondition( + record[condition] ?? null, + conditions, + pattern, + ); + if (found !== undefined) return found; + } + } + return undefined; +} + +function bareName(specifier: string): string { + const parts = specifier.split("/"); + return specifier.startsWith("@") + ? `${parts[0]}/${parts[1]}` + : (parts[0] ?? specifier); +} diff --git a/scripts/pipboy/src/footprint/pack.ts b/scripts/pipboy/src/footprint/pack.ts new file mode 100644 index 0000000000..388977ced4 --- /dev/null +++ b/scripts/pipboy/src/footprint/pack.ts @@ -0,0 +1,87 @@ +import { join } from "node:path"; +import { Effect, FileSystem, Schema } from "effect"; +import { discoverPackages } from "publish/src/lib/packages.ts"; +import { PACK_VERSION, REGISTRY_PINNED, REPO_ROOT } from "../config.ts"; +import { run } from "../exec.ts"; +import { seedAssets } from "./assets.ts"; +import { PackManifest } from "../schema/footprint.ts"; + +export class PackFailed extends Schema.TaggedError()("PackFailed", { + packageName: Schema.String, + expected: Schema.String, +}) { + override get message() { + return `pnpm pack did not produce ${this.expected} for ${this.packageName}`; + } +} + +/** Workspace packages `npm publish` ships, minus per-platform binaries and registry-pinned metas. */ +export const packablePackages = (root: string = REPO_ROOT) => + discoverPackages(root).filter((pkg) => !pkg.relDir.includes("/npm/") && !REGISTRY_PINNED.includes(pkg.name)); + +/** + * Builds and packs every publishable workspace package of the checkout at + * `root` into `outDir`, the way `npm publish` would. All packages get + * `PACK_VERSION` so `workspace:*` references between them resolve to these + * tarballs. Original `package.json` files are restored afterwards, even on + * failure. WASM commands and default software come from the latest + * published tarballs instead of being built. + */ +export const pack = Effect.fn("Footprint.pack")(function* (root: string, outDir: string) { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(outDir, { recursive: true }); + const packages = packablePackages(root); + + yield* seedAssets(root); + yield* run("pnpm", ["exec", "turbo", "run", "build", "--output-logs=errors-only", ...packages.map((pkg) => `--filter=${pkg.name}`)], { + cwd: root, + }); + + const originals = yield* Effect.forEach(packages, (pkg) => + fs.readFileString(join(pkg.dir, "package.json")).pipe(Effect.map((text) => ({ pkg, text }))), + ); + const restore = Effect.forEach( + originals, + ({ pkg, text }) => fs.writeFileString(join(pkg.dir, "package.json"), text), + { discard: true }, + ).pipe(Effect.orDie); + + const tarballs = yield* Effect.acquireUseRelease( + Effect.forEach( + originals, + ({ pkg, text }) => + fs.writeFileString( + join(pkg.dir, "package.json"), + `${JSON.stringify({ ...JSON.parse(text), version: PACK_VERSION }, null, "\t")}\n`, + ), + { discard: true }, + ), + () => + Effect.forEach( + packages, + (pkg) => + Effect.gen(function* () { + yield* run("pnpm", ["pack", "--pack-destination", outDir], { cwd: pkg.dir }); + // pnpm names tarballs `--.tgz`. Prepack scripts can + // print to stdout, so the name is derived rather than parsed. + const file = `${pkg.name.replace(/^@/, "").replace("/", "-")}-${PACK_VERSION}.tgz`; + if (!(yield* fs.exists(join(outDir, file)))) { + return yield* new PackFailed({ packageName: pkg.name, expected: file }); + } + return [pkg.name, file] as const; + }), + { concurrency: 4 }, + ), + () => restore, + ); + + const manifest: PackManifest = { version: PACK_VERSION, tarballs: Object.fromEntries(tarballs) }; + yield* fs.writeFileString(join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2)); + return manifest; +}); + +/** The manifest `pack` writes next to the tarballs. */ +export const readPackManifest = Effect.fn("Footprint.readPackManifest")(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PackManifest))(yield* fs.readFileString(join(dir, "manifest.json"))); +}); diff --git a/scripts/pipboy/src/footprint/registry.ts b/scripts/pipboy/src/footprint/registry.ts new file mode 100644 index 0000000000..98ca873b2e --- /dev/null +++ b/scripts/pipboy/src/footprint/registry.ts @@ -0,0 +1,114 @@ +import { + Cache, + Context, + Duration, + Effect, + Exit, + Layer, + Option, + Schema, +} from "effect"; +import { + HttpClient, + type HttpClientError, + HttpClientResponse, +} from "effect/http"; +import { npmEnv, run } from "../exec.ts"; +import { unpackedSize } from "./tarball.ts"; + +const VersionManifest = Schema.Struct({ + dist: Schema.Struct({ + tarball: Schema.String, + unpackedSize: Schema.optionalKey(Schema.Number), + }), +}); + +const Packument = Schema.Struct({ + "dist-tags": Schema.Record(Schema.String, Schema.String), +}); + +type RegistryError = HttpClientError.HttpClientError | Schema.SchemaError; + +export interface Interface { + /** Base URL of the registry npm itself uses, so `.npmrc` and `npm_config_registry` apply. */ + readonly url: string; + /** + * Unpacked size of `name@version`. Lookups are cached, so the same package + * across the latest, base, and PR sides costs one request. + */ + readonly packageSize: ( + name: string, + version: string, + ) => Effect.Effect; + /** The `latest` version of a package, or none if it isn't published. Cached like `packageSize`. */ + readonly latestVersion: ( + name: string, + ) => Effect.Effect, RegistryError>; +} + +export class Registry extends Context.Service()( + "pipboy/Registry", +) {} + +const encodeName = (name: string) => name.replace("/", "%2F"); + +/** Cache successes for the whole run; drop failures so the next caller retries. */ +const keepSuccesses = (exit: Exit.Exit) => + Exit.isSuccess(exit) ? Duration.infinity : Duration.zero; + +export const layer = Layer.effect( + Registry, + Effect.gen(function* () { + const configured = (yield* run("npm", ["config", "get", "registry"], { + env: npmEnv(), + })).trim(); + const url = configured.endsWith("/") ? configured : `${configured}/`; + const http = (yield* HttpClient.HttpClient).pipe( + HttpClient.retryTransient({ times: 4 }), + ); + + // Old packages (published with npm < 5) have no `unpackedSize`; their tarball is measured instead. + const lookupSize = Effect.fn("Registry.packageSize")(function* ( + id: string, + ) { + const at = id.lastIndexOf("@"); + const manifest = yield* http + .get(`${url}${encodeName(id.slice(0, at))}/${id.slice(at + 1)}`) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(VersionManifest)), + ); + if (manifest.dist.unpackedSize !== undefined) + return manifest.dist.unpackedSize; + const tarball = yield* http + .get(manifest.dist.tarball) + .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + return unpackedSize(new Uint8Array(yield* tarball.arrayBuffer)); + }); + const sizes = yield* Cache.makeWith(lookupSize, { + capacity: 20_000, + timeToLive: keepSuccesses, + }); + + const lookupLatest = Effect.fn("Registry.latestVersion")(function* ( + name: string, + ) { + const response = yield* http.get(`${url}${encodeName(name)}`); + if (response.status === 404) return Option.none(); + const packument = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(Packument)), + ); + return Option.fromNullishOr(packument["dist-tags"].latest); + }); + const latest = yield* Cache.makeWith(lookupLatest, { + capacity: 1_000, + timeToLive: keepSuccesses, + }); + + return Registry.of({ + url, + packageSize: (name, version) => Cache.get(sizes, `${name}@${version}`), + latestVersion: (name) => Cache.get(latest, name), + }); + }), +); diff --git a/scripts/pipboy/src/footprint/resolve.ts b/scripts/pipboy/src/footprint/resolve.ts new file mode 100644 index 0000000000..78f023b8c5 --- /dev/null +++ b/scripts/pipboy/src/footprint/resolve.ts @@ -0,0 +1,250 @@ +import { join, resolve } from "node:path"; +import { Effect, FileSystem, Option, Schema } from "effect"; +import { REGISTRY_PINNED } from "../config.ts"; +import { npmEnv, run } from "../exec.ts"; +import type { PackageNode, SideName } from "../schema/footprint.ts"; +import { readPackManifest } from "./pack.ts"; +import { Registry } from "./registry.ts"; +import { readTarball, unpackedSize } from "./tarball.ts"; + +const StringRecord = Schema.Record(Schema.String, Schema.String); + +const LockEntry = Schema.Struct({ + name: Schema.optionalKey(Schema.String), + version: Schema.optionalKey(Schema.String), + resolved: Schema.optionalKey(Schema.String), + dependencies: Schema.optionalKey(StringRecord), + optionalDependencies: Schema.optionalKey(StringRecord), + peerDependencies: Schema.optionalKey(StringRecord), + peerDependenciesMeta: Schema.optionalKey( + Schema.Record( + Schema.String, + Schema.Struct({ optional: Schema.optionalKey(Schema.Boolean) }), + ), + ), + os: Schema.optionalKey(Schema.Array(Schema.String)), + cpu: Schema.optionalKey(Schema.Array(Schema.String)), + libc: Schema.optionalKey(Schema.Array(Schema.String)), + hasInstallScript: Schema.optionalKey(Schema.Boolean), +}); +type LockEntry = Schema.Schema.Type; + +const Lockfile = Schema.Struct({ + lockfileVersion: Schema.Literal(3), + packages: Schema.Record(Schema.String, LockEntry), +}); + +export type SideInput = + | { readonly side: "latest"; readonly roots: ReadonlyArray } + | { readonly side: Exclude; readonly tarballDir: string }; + +const TarballManifest = Schema.Struct({ + dependencies: Schema.optionalKey(StringRecord), + optionalDependencies: Schema.optionalKey(StringRecord), + peerDependencies: Schema.optionalKey(StringRecord), +}); + +/** + * Resolves a side's dependency trees exactly as `npm install` would, without + * downloading them: `npm install --package-lock-only` writes a lockfile, and + * sizes come from registry metadata. Install scripts never run. + * + * Each root resolves in its own project, as a user installs one package. A + * conflict between two of our packages, such as a stale peer range, then + * can't break the measurement of the others. Node keys are prefixed with the + * root's name, so the trees never collide. + */ +export const resolveSide = Effect.fn("Footprint.resolveSide")(function* ( + input: SideInput, +) { + const fs = yield* FileSystem.FileSystem; + const registry = yield* Registry; + + /** `name -> latest version` for the packages in `names` that are published. */ + const latestVersions = (names: ReadonlyArray) => + Effect.forEach( + names, + (name) => + registry + .latestVersion(name) + .pipe(Effect.map((v) => [name, v] as const)), + { + concurrency: 16, + }, + ).pipe( + Effect.map((pairs) => + Object.fromEntries( + pairs.flatMap(([name, v]) => + Option.isSome(v) ? [[name, v.value]] : [], + ), + ), + ), + ); + + const pinned = yield* latestVersions(REGISTRY_PINNED); + const localTarballs = new Map(); + /** Root name to the dependencies of the project that installs it. */ + const projects = new Map>(); + if (input.side === "latest") { + for (const [name, version] of Object.entries( + yield* latestVersions(input.roots), + )) + projects.set(name, { [name]: version }); + } else { + const manifestDir = resolve(input.tarballDir); + const manifest = yield* readPackManifest(manifestDir); + for (const [name, file] of Object.entries(manifest.tarballs)) + localTarballs.set(name, join(manifestDir, file)); + // Packed packages depend on each other at `PACK_VERSION`, which only exists as a local tarball. + const workspaceDeps = new Map>(); + for (const [name, path] of localTarballs) { + const entry = readTarball(yield* fs.readFile(path)).find( + (e) => e.path === "package/package.json", + ); + const pkg = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString(TarballManifest), + )(new TextDecoder().decode(entry?.data)); + const specs = { + ...pkg.dependencies, + ...pkg.optionalDependencies, + ...pkg.peerDependencies, + }; + workspaceDeps.set( + name, + Object.keys(specs).filter( + (dep) => specs[dep] === manifest.version && localTarballs.has(dep), + ), + ); + } + for (const name of localTarballs.keys()) { + const closure = new Set([name]); + for (const dep of closure) + for (const next of workspaceDeps.get(dep) ?? []) closure.add(next); + projects.set( + name, + Object.fromEntries( + [...closure].map((dep) => [dep, `file:${localTarballs.get(dep)}`]), + ), + ); + } + } + + const npmVersion = (yield* run("npm", ["--version"], { + env: npmEnv(), + })).trim(); + const nodes: Record = {}; + const roots: Record = {}; + yield* Effect.forEach( + projects, + ([root, dependencies]) => + Effect.gen(function* () { + const dir = yield* fs.makeTempDirectoryScoped({ + prefix: `pipboy-${input.side}-`, + }); + yield* fs.writeFileString( + join(dir, "package.json"), + JSON.stringify({ + name: `pipboy-${input.side}`, + private: true, + dependencies, + overrides: pinned, + }), + ); + yield* run( + "npm", + [ + "install", + "--package-lock-only", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ], + { cwd: dir, env: npmEnv() }, + ); + const lockfile = yield* fs + .readFileString(join(dir, "package-lock.json")) + .pipe( + Effect.flatMap( + Schema.decodeUnknownEffect(Schema.fromJsonString(Lockfile)), + ), + ); + const prefixed = (key: string) => `${root}::${key}`; + + const entries = Object.entries(lockfile.packages).filter( + ([key]) => key !== "", + ); + yield* Effect.forEach( + entries, + ([key, entry]) => + Effect.gen(function* () { + const name = packageName(key, entry); + const local = localTarballs.get(name); + const bytes = + local !== undefined + ? unpackedSize(yield* fs.readFile(local)) + : yield* registry.packageSize(name, entry.version ?? ""); + nodes[prefixed(key)] = { + name, + version: entry.version ?? "", + bytes, + ...(entry.os ? { os: entry.os } : {}), + ...(entry.cpu ? { cpu: entry.cpu } : {}), + ...(entry.libc ? { libc: entry.libc } : {}), + installScript: entry.hasInstallScript ?? false, + deps: installedDependencies(entry).flatMap((dep) => { + const found = lookup(lockfile.packages, key, dep); + return found === undefined ? [] : [prefixed(found)]; + }), + zeroXRanges: Object.fromEntries( + Object.entries({ + ...entry.dependencies, + ...entry.optionalDependencies, + }).filter(([, range]) => /^\^0\./.test(range)), + ), + }; + }), + { concurrency: 32, discard: true }, + ); + const rootKey = lookup(lockfile.packages, "", root); + if (rootKey !== undefined) roots[root] = prefixed(rootKey); + }), + { concurrency: 4, discard: true }, + ); + return { side: input.side, npmVersion, roots, nodes, entrypoints: {} }; +}); + +function packageName(key: string, entry: LockEntry): string { + return ( + entry.name ?? + key.slice(key.lastIndexOf("node_modules/") + "node_modules/".length) + ); +} + +/** Dependencies npm installs by default: regular, optional, and non-optional peers. */ +function installedDependencies(entry: LockEntry): Array { + const peers = Object.keys(entry.peerDependencies ?? {}).filter( + (name) => entry.peerDependenciesMeta?.[name]?.optional !== true, + ); + return [ + ...Object.keys(entry.dependencies ?? {}), + ...Object.keys(entry.optionalDependencies ?? {}), + ...peers, + ]; +} + +/** Node's module lookup over lockfile keys: nearest `node_modules` first, then each ancestor. */ +function lookup( + packages: Record, + from: string, + dep: string, +): string | undefined { + let base = from; + while (true) { + const candidate = + base === "" ? `node_modules/${dep}` : `${base}/node_modules/${dep}`; + if (candidate in packages) return candidate; + if (base === "") return undefined; + const parent = base.lastIndexOf("/node_modules/"); + base = parent === -1 ? "" : base.slice(0, parent); + } +} diff --git a/scripts/pipboy/src/footprint/tarball.ts b/scripts/pipboy/src/footprint/tarball.ts new file mode 100644 index 0000000000..4f4acf1b3d --- /dev/null +++ b/scripts/pipboy/src/footprint/tarball.ts @@ -0,0 +1,43 @@ +import { gunzipSync } from "node:zlib"; + +export interface TarEntry { + readonly path: string; + readonly size: number; + readonly data: Uint8Array; +} + +/** Reads the regular files of a gzipped tarball, such as an npm package. */ +export function readTarball(gzipped: Uint8Array): Array { + const tar = gunzipSync(gzipped); + const entries: Array = []; + let offset = 0; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) break; + const name = field(header, 0, 100); + const prefix = field(header, 345, 155); + const size = Number.parseInt(field(header, 124, 12).trim() || "0", 8); + const type = String.fromCharCode(header[156] ?? 0); + const start = offset + 512; + if (type === "0" || type === "\0") { + entries.push({ + path: prefix ? `${prefix}/${name}` : name, + size, + data: tar.subarray(start, start + size), + }); + } + offset = start + Math.ceil(size / 512) * 512; + } + return entries; +} + +/** Unpacked size: the sum of file sizes, the same thing npm reports as `unpackedSize`. */ +export function unpackedSize(gzipped: Uint8Array): number { + return readTarball(gzipped).reduce((sum, entry) => sum + entry.size, 0); +} + +function field(header: Uint8Array, start: number, length: number): string { + const bytes = header.subarray(start, start + length); + const end = bytes.indexOf(0); + return new TextDecoder().decode(end === -1 ? bytes : bytes.subarray(0, end)); +} diff --git a/scripts/pipboy/src/github/api.ts b/scripts/pipboy/src/github/api.ts new file mode 100644 index 0000000000..a1aa59ff93 --- /dev/null +++ b/scripts/pipboy/src/github/api.ts @@ -0,0 +1,97 @@ +import { Config, Context, Effect, Layer, Redacted, type Schema } from "effect"; +import { + type HttpBody, + HttpClient, + type HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/http"; + +export interface Interface { + readonly repository: string; + readonly get: ( + path: string, + schema: S, + ) => Effect.Effect< + S["Type"], + HttpClientError.HttpClientError | Schema.SchemaError, + S["DecodingServices"] + >; + readonly send: ( + method: "POST" | "PATCH", + path: string, + body: unknown, + ) => Effect.Effect< + void, + HttpClientError.HttpClientError | HttpBody.HttpBodyError + >; +} + +export class GitHub extends Context.Service()( + "pipboy/GitHub", +) {} + +/** The current GitHub Actions run, read from the standard runner environment. */ +export const CurrentRun = Config.all({ + repository: Config.String("GITHUB_REPOSITORY"), + runId: Config.String("GITHUB_RUN_ID"), + runAttempt: Config.String("GITHUB_RUN_ATTEMPT").pipe(Config.withDefault("1")), + serverUrl: Config.String("GITHUB_SERVER_URL").pipe( + Config.withDefault("https://github.com"), + ), +}).pipe( + Config.map((run) => ({ + ...run, + url: `${run.serverUrl}/${run.repository}/actions/runs/${run.runId}`, + })), +); + +/** A GitHub API client for `repository`, authenticated with `token` or `GITHUB_TOKEN`. */ +export const layerFor = ( + repository: string, + explicitToken?: Redacted.Redacted, +) => + Layer.effect( + GitHub, + Effect.gen(function* () { + const token = explicitToken ?? (yield* Config.Redacted("GITHUB_TOKEN")); + const apiUrl = yield* Config.String("GITHUB_API_URL").pipe( + Config.withDefault("https://api.github.com"), + ); + const http = (yield* HttpClient.HttpClient).pipe( + HttpClient.mapRequest((request) => + request.pipe( + HttpClientRequest.prependUrl(apiUrl), + HttpClientRequest.bearerToken(Redacted.value(token)), + HttpClientRequest.setHeader( + "accept", + "application/vnd.github+json", + ), + HttpClientRequest.setHeader("x-github-api-version", "2022-11-28"), + // GitHub rejects API requests without a User-Agent. + HttpClientRequest.setHeader("user-agent", "rivet-pipboy"), + ), + ), + HttpClient.filterStatusOk, + HttpClient.retryTransient({ times: 4 }), + ); + return GitHub.of({ + repository, + get: (path, schema) => + http + .get(path) + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema))), + send: (method, path, body) => + HttpClientRequest.make(method)(path).pipe( + HttpClientRequest.bodyJson(body), + Effect.flatMap(http.execute), + Effect.asVoid, + ), + }); + }), + ); + +/** A client for the repository of the current Actions run. */ +export const layer = Layer.unwrap( + Config.String("GITHUB_REPOSITORY").pipe(Effect.map(layerFor)), +); diff --git a/scripts/pipboy/src/github/baseline.ts b/scripts/pipboy/src/github/baseline.ts new file mode 100644 index 0000000000..4fae49324f --- /dev/null +++ b/scripts/pipboy/src/github/baseline.ts @@ -0,0 +1,143 @@ +import { Effect, Option, Schema } from "effect"; +import { run } from "../exec.ts"; +import { GitHub } from "./api.ts"; + +export const WORKFLOW_PATH = ".github/workflows/pipboy.yml"; + +const Runs = Schema.Struct({ + workflow_runs: Schema.Array( + Schema.Struct({ + id: Schema.Number, + path: Schema.String, + event: Schema.String, + head_branch: Schema.NullOr(Schema.String), + head_sha: Schema.String, + head_repository: Schema.Struct({ full_name: Schema.String }), + }), + ), +}); + +const Artifacts = Schema.Struct({ + artifacts: Schema.Array( + Schema.Struct({ name: Schema.String, expired: Schema.Boolean }), + ), +}); + +type Run = Schema.Schema.Type["workflow_runs"][number]; + +/** + * This workflow's completed push runs on `main` in this repository, newest + * first. Nothing else is trusted as a baseline: a PR run can never supply one. + * Empty until this workflow exists on main, when the API answers 404. + */ +const listMainRuns = Effect.fn("Baseline.listMainRuns")(function* ( + filter: string, + pages: number, +) { + const github = yield* GitHub; + const runs: Array = []; + for (let page = 1; page <= pages; page++) { + const { workflow_runs } = yield* github + .get( + `/repos/${github.repository}/actions/workflows/pipboy.yml/runs?event=push&branch=main&status=completed${filter}&per_page=100&page=${page}`, + Runs, + ) + .pipe( + Effect.catchIf( + (error) => + error._tag === "HttpClientError" && error.response?.status === 404, + () => Effect.succeed({ workflow_runs: [] }), + ), + ); + runs.push( + ...workflow_runs.filter( + (r) => + r.event === "push" && + r.head_branch === "main" && + r.path === WORKFLOW_PATH && + r.head_repository.full_name === github.repository, + ), + ); + if (workflow_runs.length < 100) break; + } + return runs; +}); + +const artifactNames = Effect.fn("Baseline.artifactNames")(function* ( + runId: number, +) { + const github = yield* GitHub; + const { artifacts } = yield* github.get( + `/repos/${github.repository}/actions/runs/${runId}/artifacts?per_page=100`, + Artifacts, + ); + return new Set(artifacts.filter((a) => !a.expired).map((a) => a.name)); +}); + +const download = Effect.fn("Baseline.download")(function* ( + runId: number, + artifact: string, + outDir: string, +) { + const github = yield* GitHub; + yield* run("gh", [ + "run", + "download", + String(runId), + "--repo", + github.repository, + "--name", + artifact, + "--dir", + outDir, + ]); +}); + +/** Downloads `artifact` from main's run for exactly `sha`. Returns the run id, or none. */ +export const fetchBaseline = Effect.fn("Baseline.fetch")(function* ( + sha: string, + artifact: string, + outDir: string, +) { + for (const r of (yield* listMainRuns(`&head_sha=${sha}`, 1)).filter( + (r) => r.head_sha === sha, + )) { + if (!(yield* artifactNames(r.id)).has(artifact)) continue; + yield* download(r.id, artifact, outDir); + return Option.some(r.id); + } + return Option.none(); +}); + +/** + * Downloads `artifacts` from the newest commit in `history` (newest first) + * whose main run produced any of them. Main produces some artifacts only for + * some commits, such as native builds only when Rust changed, so the result + * names the commit the baseline came from. + */ +export const fetchNearestBaselines = Effect.fn("Baseline.fetchNearest")( + function* ( + history: ReadonlyArray, + artifacts: ReadonlyArray, + outDir: string, + ) { + if (history.length === 0 || artifacts.length === 0) + return Option.none<{ sha: string; found: ReadonlyArray }>(); + const bySha = new Map>(); + for (const r of yield* listMainRuns("", 3)) + bySha.set(r.head_sha, [...(bySha.get(r.head_sha) ?? []), r]); + for (const sha of history) { + for (const r of bySha.get(sha) ?? []) { + const available = yield* artifactNames(r.id); + const found = artifacts.filter((a) => available.has(a)); + if (found.length === 0) continue; + yield* Effect.forEach(found, (a) => download(r.id, a, outDir), { + concurrency: 4, + discard: true, + }); + return Option.some({ sha, found }); + } + } + return Option.none<{ sha: string; found: ReadonlyArray }>(); + }, +); diff --git a/scripts/pipboy/src/github/comment.ts b/scripts/pipboy/src/github/comment.ts new file mode 100644 index 0000000000..11a6102733 --- /dev/null +++ b/scripts/pipboy/src/github/comment.ts @@ -0,0 +1,59 @@ +import { Effect, Schema } from "effect"; +import { GitHub } from "./api.ts"; + +export const MARKER = ""; +const BOT = "github-actions[bot]"; + +const Pull = Schema.Struct({ head: Schema.Struct({ sha: Schema.String }) }); +const Comments = Schema.Array( + Schema.Struct({ + id: Schema.Number, + body: Schema.optionalKey(Schema.String), + user: Schema.NullOr(Schema.Struct({ login: Schema.String })), + }), +); + +/** + * Creates or updates the one Pip-Boy comment on a PR. Skips the write when + * `headSha` is no longer the PR head, so a run for an older push can't + * overwrite a newer result. + */ +export const upsertComment = Effect.fn("Comment.upsert")(function* ( + prNumber: number, + headSha: string, + body: string, +) { + const github = yield* GitHub; + const repo = github.repository; + const pull = yield* github.get(`/repos/${repo}/pulls/${prNumber}`, Pull); + if (pull.head.sha !== headSha) { + yield* Effect.logInfo("skipping comment for a stale run").pipe( + Effect.annotateLogs({ headSha, currentHead: pull.head.sha }), + ); + return "stale" as const; + } + + let existing: number | undefined; + for (let page = 1; existing === undefined; page++) { + const comments = yield* github.get( + `/repos/${repo}/issues/${prNumber}/comments?per_page=100&page=${page}`, + Comments, + ); + existing = comments.find( + (c) => c.user?.login === BOT && c.body?.startsWith(MARKER), + )?.id; + if (comments.length < 100) break; + } + + const content = `${MARKER}\n${body}`; + if (existing === undefined) { + yield* github.send("POST", `/repos/${repo}/issues/${prNumber}/comments`, { + body: content, + }); + return "created" as const; + } + yield* github.send("PATCH", `/repos/${repo}/issues/comments/${existing}`, { + body: content, + }); + return "updated" as const; +}); diff --git a/scripts/pipboy/src/github/timing.ts b/scripts/pipboy/src/github/timing.ts new file mode 100644 index 0000000000..68363bf803 --- /dev/null +++ b/scripts/pipboy/src/github/timing.ts @@ -0,0 +1,105 @@ +import { Effect, Schema } from "effect"; +import { code } from "../render/format.ts"; +import { stepLabel, toMillis } from "../trace/spans.ts"; +import { CurrentRun, GitHub } from "./api.ts"; + +const Step = Schema.Struct({ + name: Schema.String, + number: Schema.Number, + conclusion: Schema.NullOr(Schema.String), + started_at: Schema.NullOr(Schema.String), + completed_at: Schema.NullOr(Schema.String), +}); + +const Job = Schema.Struct({ + id: Schema.Number, + name: Schema.String, + conclusion: Schema.NullOr(Schema.String), + created_at: Schema.optionalKey(Schema.String), + started_at: Schema.NullOr(Schema.String), + completed_at: Schema.NullOr(Schema.String), + html_url: Schema.optionalKey(Schema.String), + steps: Schema.optionalKey(Schema.Array(Step)), +}); +type Job = Schema.Schema.Type; + +const Jobs = Schema.Struct({ jobs: Schema.Array(Job) }); + +export interface Timing { + readonly jobs: ReadonlyArray; + /** Wall clock time the jobs were read. Running jobs end here. */ + readonly now: number; +} + +/** Every job of one run attempt, including still-running jobs. */ +export const readJobs = Effect.fn("Timing.readJobs")(function* ( + runId: number | string, + attempt: number | string, +) { + const github = yield* GitHub; + const jobs: Array = []; + for (let page = 1; ; page++) { + const batch = yield* github.get( + `/repos/${github.repository}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100&page=${page}`, + Jobs, + ); + jobs.push(...batch.jobs); + if (batch.jobs.length < 100) return jobs; + } +}); + +/** Job and step timings of the current run. */ +export const readTiming = Effect.fn("Timing.read")(function* () { + const run = yield* CurrentRun; + const jobs = yield* readJobs(run.runId, run.runAttempt); + return { + jobs: jobs.filter((job) => job.started_at !== null), + now: Date.now(), + } satisfies Timing; +}); + +function duration(totalMs: number): string { + const s = Math.max(0, Math.round(totalMs / 1000)); + return s >= 60 + ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s` + : `${s}s`; +} + +/** Comment section: wall time per job and its slowest step. Job names link to their logs. */ +export function renderTiming(timing: Timing): string { + const lines = [ + "### ⏱ Timing", + "", + "| job | wall time | slowest step |", + "|---|---:|---|", + ]; + for (const job of timing.jobs) { + const start = toMillis(job.started_at, timing.now); + const slowest = [...(job.steps ?? [])] + .map((s) => ({ + name: s.name, + ms: + toMillis(s.completed_at, timing.now) - + toMillis(s.started_at, timing.now), + })) + .sort((a, b) => b.ms - a.ms)[0]; + const name = + job.html_url === undefined + ? code(job.name) + : `[${code(job.name)}](${job.html_url})`; + if (job.conclusion === "skipped") { + lines.push(`| ⏭ ${name} | — | skipped |`); + continue; + } + const icon = + job.conclusion === "failure" + ? "❌ " + : job.conclusion === "cancelled" + ? "⊘ " + : ""; + lines.push( + `| ${icon}${name} | ${duration(toMillis(job.completed_at, timing.now) - start)} | ${slowest ? `${code(stepLabel(slowest.name))} · ${duration(slowest.ms)}` : "—"} |`, + ); + } + return lines.join("\n"); +} diff --git a/scripts/pipboy/src/hygiene/collect.ts b/scripts/pipboy/src/hygiene/collect.ts new file mode 100644 index 0000000000..d4d0d2e816 --- /dev/null +++ b/scripts/pipboy/src/hygiene/collect.ts @@ -0,0 +1,221 @@ +import { Effect, Schema } from "effect"; +import { type Captured, capture, commandFailed } from "../exec.ts"; +import type { HygieneCheck, HygieneFinding } from "../schema/hygiene.ts"; + +interface Krate { + readonly name: string; + readonly version: string; +} +interface Graph { + readonly Krate: Krate; + readonly parents?: ReadonlyArray; +} + +const Graph: Schema.Codec = Schema.Struct({ + Krate: Schema.Struct({ name: Schema.String, version: Schema.String }), + parents: Schema.optionalKey(Schema.Array(Schema.suspend(() => Graph))), +}); + +const DenyLine = Schema.Struct({ + type: Schema.String, + fields: Schema.optionalKey( + Schema.Struct({ + severity: Schema.optionalKey(Schema.String), + code: Schema.optionalKey(Schema.String), + message: Schema.optionalKey(Schema.String), + advisory: Schema.optionalKey( + Schema.NullOr(Schema.Struct({ id: Schema.String })), + ), + graphs: Schema.optionalKey(Schema.Array(Graph)), + }), + ), +}); + +const ShearOutput = Schema.Struct({ + findings: Schema.Array( + Schema.Struct({ + code: Schema.String, + severity: Schema.String, + message: Schema.String, + file: Schema.optionalKey(Schema.String), + }), + ), +}); + +/** + * Every cargo-deny 0.20.2 diagnostic code, by check, from the `Code` enums in + * its source. Update together with the pinned cargo-deny version. + */ +const DENY_CODES: Record = { + vulnerability: "advisories", + notice: "advisories", + unmaintained: "advisories", + unsound: "advisories", + yanked: "advisories", + "advisory-ignored": "advisories", + "yanked-ignored": "advisories", + "index-failure": "advisories", + "index-cache-load-failure": "advisories", + "advisory-not-detected": "advisories", + "yanked-not-detected": "advisories", + "unknown-advisory": "advisories", + accepted: "licenses", + rejected: "licenses", + unlicensed: "licenses", + "skipped-private-workspace-crate": "licenses", + "license-not-encountered": "licenses", + "license-exception-not-encountered": "licenses", + "missing-clarification-file": "licenses", + "parse-error": "licenses", + "empty-license-field": "licenses", + "no-license-field": "licenses", + "gather-failure": "licenses", + "git-source-underspecified": "sources", + "allowed-source": "sources", + "allowed-by-organization": "sources", + "source-not-allowed": "sources", + "unmatched-source": "sources", + "unmatched-organization": "sources", + banned: "bans", + allowed: "bans", + "not-allowed": "bans", + duplicate: "bans", + skipped: "bans", + wildcard: "bans", + "unmatched-skip": "bans", + "unnecessary-skip": "bans", + "allowed-by-wrapper": "bans", + "unmatched-wrapper": "bans", + "skipped-by-root": "bans", + "unmatched-skip-root": "bans", + "build-script-not-allowed": "bans", + "exact-features-mismatch": "bans", + "feature-not-explicitly-allowed": "bans", + "feature-banned": "bans", + "unknown-feature": "bans", + "default-feature-enabled": "bans", + "path-bypassed": "bans", + "path-bypassed-by-glob": "bans", + "checksum-match": "bans", + "checksum-mismatch": "bans", + "denied-by-extension": "bans", + "detected-executable": "bans", + "detected-executable-script": "bans", + "unable-to-check-path": "bans", + "features-enabled": "bans", + "unmatched-bypass": "bans", + "unmatched-path-bypass": "bans", + "unmatched-glob": "bans", + "unused-wrapper": "bans", + "workspace-duplicate": "bans", + "unresolved-workspace-dependency": "bans", + "unused-workspace-dependency": "bans", + "non-root-path": "bans", + "replaced-in-std": "bans", + "unmatched-replacement-ignore": "bans", +}; + +/** Names from a workspace crate down to the reported crate, following the first parent at each level. */ +function via(graph: Graph): string { + const names: Array = []; + let node: Graph | undefined = graph; + while (node) { + names.unshift(node.Krate.name); + node = node.parents?.[0]; + } + return names.join(" › "); +} + +/** + * Runs `cargo deny` and `cargo shear` on the checkout at `root`. Each side uses + * its own `deny.toml`, so a PR that fixes a finding through config shows it as + * fixed. Neither tool compiles anything. + */ +export class UnknownDenyCode extends Schema.TaggedError()( + "UnknownDenyCode", + { + code: Schema.String, + }, +) { + override get message() { + return `cargo-deny reported the unknown diagnostic code \`${this.code}\`; update DENY_CODES for the pinned cargo-deny version`; + } +} + +export const collectHygiene = Effect.fn("Hygiene.collect")(function* ( + root: string, +) { + // cargo-deny exits non-zero when it finds issues and writes its JSON to stderr, + // so success is judged by whether it reached its summary line. + const deny = yield* capture( + "cargo", + [ + "deny", + "--format", + "json", + "--log-level", + "error", + "check", + "advisories", + "licenses", + "sources", + "bans", + ], + { cwd: root }, + ); + const lines = deny.stderr.split("\n").filter((line) => line.startsWith("{")); + const parsed = yield* Effect.forEach(lines, (line) => + Schema.decodeUnknownEffect(Schema.fromJsonString(DenyLine))(line).pipe( + Effect.mapError(() => commandFailed(deny)), + ), + ); + if (!parsed.some((line) => line.type === "summary")) + return yield* commandFailed(deny); + + const findings: Array = []; + for (const { type, fields: f } of parsed) { + if (type !== "diagnostic" || f?.severity !== "error" || !f.code) continue; + const check = DENY_CODES[f.code]; + if (check === undefined) + return yield* new UnknownDenyCode({ code: f.code }); + const graph = f.graphs?.[0]; + const krate = graph ? `${graph.Krate.name}@${graph.Krate.version}` : ""; + const id = f.advisory?.id; + findings.push({ + check, + // The crate version is left out so a routine version bump of an already-flagged crate isn't "new". + key: [f.code, id, graph?.Krate.name].filter(Boolean).join(":"), + title: id + ? `${id} ${krate}: ${f.message ?? ""}` + : `${krate}: ${f.message ?? f.code}`, + via: graph ? via(graph) : "", + }); + } + + // cargo-shear exits 1 when it finds issues. Any other exit, or output that isn't + // its JSON report, is a failure of the tool itself. + const shearRun = yield* capture("cargo", ["shear", "--format", "json"], { + cwd: root, + }); + const shear = yield* decodeReport(shearRun, ShearOutput); + for (const f of shear.findings) { + if (f.severity !== "error" && f.severity !== "warning") continue; + findings.push({ + check: "unused", + key: `${f.code}:${f.file ?? ""}:${f.message}`, + title: f.message.split("\n")[0] ?? f.message, + via: f.file ?? "", + }); + } + return findings; +}); + +const decodeReport = ( + captured: Captured, + schema: S, +) => + captured.exitCode === 0 || captured.exitCode === 1 + ? Schema.decodeUnknownEffect(Schema.fromJsonString(schema))( + captured.stdout, + ).pipe(Effect.mapError(() => commandFailed(captured))) + : Effect.fail(commandFailed(captured)); diff --git a/scripts/pipboy/src/native/measure.ts b/scripts/pipboy/src/native/measure.ts new file mode 100644 index 0000000000..ce4b186f42 --- /dev/null +++ b/scripts/pipboy/src/native/measure.ts @@ -0,0 +1,65 @@ +import { Effect, FileSystem, Option } from "effect"; +import { capture } from "../exec.ts"; +import { Registry } from "../footprint/registry.ts"; +import type { NativeReport } from "../schema/native.ts"; + +/** The npm package that ships each build artifact. */ +const NPM_PACKAGE: Record string> = { + "agentos-native-sidecar": (platform) => `@rivet-dev/agentos-runtime-sidecar-${platform}`, +}; + +/** + * Measures one published binary: its size, and symbol bytes per crate. Crates + * come from demangled symbol names (`crate::module::item`), so the split is + * approximate; symbols without a Rust path count as `[c/other]`. + */ +export const measureNative = Effect.fn("Native.measure")(function* ( + target: string, + platform: string, + path: string, +) { + const fs = yield* FileSystem.FileSystem; + const bytes = Number((yield* fs.stat(path)).size); + const crates = yield* crateSizes(path); + + const latestBytes = yield* latestPublishedSize( + NPM_PACKAGE[target]?.(platform), + ); + return { + schema: 1, + check: "native", + artifact: { target, platform, bytes, crates }, + ...(latestBytes !== undefined ? { latestBytes } : {}), + } satisfies NativeReport; +}); + +/** Unpacked size of the npm package's `latest` version, if it's published. */ +const latestPublishedSize = Effect.fn("Native.latestPublishedSize")(function* ( + name: string | undefined, +) { + if (name === undefined) return undefined; + const registry = yield* Registry; + const version = yield* registry.latestVersion(name); + return Option.isSome(version) + ? yield* registry.packageSize(name, version.value) + : undefined; +}); + +// Stripped binaries, and Mach-O or PE binaries the runner's GNU nm can't read, report no crates. +const crateSizes = (path: string) => + capture("nm", ["--demangle", "--print-size", "--size-sort", path]).pipe( + Effect.map(({ exitCode, stdout }) => { + const crates: Record = {}; + if (exitCode !== 0) return crates; + for (const line of stdout.split("\n")) { + // `
`, sizes in hex. + const match = line.match(/^[0-9a-f]+ ([0-9a-f]+) \S (.+)$/i); + if (!match) continue; + const crate = + match[2]?.match(/([A-Za-z_][A-Za-z0-9_]*)::/)?.[1] ?? "[c/other]"; + crates[crate] = + (crates[crate] ?? 0) + Number.parseInt(match[1] ?? "0", 16); + } + return crates; + }), + ); diff --git a/scripts/pipboy/src/render/footprint.ts b/scripts/pipboy/src/render/footprint.ts new file mode 100644 index 0000000000..a4ed96d426 --- /dev/null +++ b/scripts/pipboy/src/render/footprint.ts @@ -0,0 +1,181 @@ +import type { + EntrypointRow, + FootprintComparison, + PackageRow, +} from "../compare/footprint.ts"; +import { GATED_PLATFORM } from "../config.ts"; +import { bytes, change, code, count, ICON } from "./format.ts"; + +export function renderFootprint(c: FootprintComparison): string { + const ref = c.reference; + const out: Array = [ + `### 📦 Packages · fresh \`npm install\` resolve, no lockfile · this PR vs ${ref === "base" ? "base" : "`@latest`"}`, + "", + ]; + if (c.latestOverBudget.length > 0) { + out.push( + `> 🔴 **Registry drift:** ${c.latestOverBudget.map((n) => code(`${n}@latest`)).join(", ")} is already over budget for anyone installing it today. This PR didn't cause it.`, + "", + ); + } + + const changed = c.rows.filter(isNotable); + if (changed.length === 0) { + out.push( + `No notable install size, dependency, or entrypoint changes across ${c.rows.length} package${c.rows.length === 1 ? "" : "s"}.`, + "", + ); + } else { + out.push(...summaryTable(changed, ref), ""); + for (const row of changed) out.push(...details(row, ref)); + } + + out.push( + `
All ${c.rows.length} packages · ${GATED_PLATFORM.id}`, + "", + ...summaryTable(c.rows, ref), + "", + "
", + ); + return out.join("\n"); +} + +/** + * A package is worth showing when this PR changed something meaningful (a size + * crossing its threshold, or a different set of dependencies, install scripts, + * imports, or exports) or when it's over budget, since its details explain why. + * Byte-level noise, such as build paths embedded in output, is not. + */ +function isNotable(row: PackageRow): boolean { + return ( + row.severity !== "ok" || + row.budget?.over.pr === true || + row.added.length + row.removed.length + row.installScriptsAdded.length > + 0 || + row.entrypoints.some((e) => e.severity !== "ok" || hasSurfaceChange(e)) + ); +} + +function summaryTable( + rows: ReadonlyArray, + ref: "latest" | "base" | "pr", +): Array { + const lines = [ + `| package (${GATED_PLATFORM.id}) | \`@latest\` | base | this PR | Δ vs ${ref} | packages |`, + "|---|---:|---:|---:|---|---:|", + ]; + for (const row of rows) { + const g = row.install[0]; + const r = g?.[ref]; + const budget = row.budget?.over.pr + ? ` · over ${bytes(row.budget.limit)} budget` + : ""; + lines.push( + [ + "", + `${ICON[row.severity]}${code(row.name)}`, + g?.latest ? bytes(g.latest.bytes) : "—", + g?.base ? bytes(g.base.bytes) : "—", + g?.pr ? bytes(g.pr.bytes) : "—", + r && g?.pr ? `${change(r.bytes, g.pr.bytes)}${budget}` : "new", + r && g?.pr + ? `${g.pr.count} (${change(r.count, g.pr.count, count)})` + : String(g?.pr?.count ?? "—"), + "", + ] + .join(" | ") + .trim(), + ); + } + return lines; +} + +function details( + row: PackageRow, + ref: "latest" | "base" | "pr", +): Array { + const out = [ + `
${ICON[row.severity]}${code(row.name)} details`, + "", + ]; + + out.push(`| platform | ${ref} | this PR | Δ |`, "|---|---:|---:|---|"); + for (const p of row.install) { + const r = p[ref]; + out.push( + `| ${p.platform} | ${r ? bytes(r.bytes) : "—"} | ${p.pr ? bytes(p.pr.bytes) : "—"} | ${r && p.pr ? change(r.bytes, p.pr.bytes) : "—"} |`, + ); + } + out.push(""); + + const refs = (title: string, list: PackageRow["added"]) => { + if (list.length === 0) return; + out.push(`**${title}**`, "", "| package | size | via |", "|---|---:|---|"); + for (const p of list) + out.push( + `| ${code(p.id)} | ${bytes(p.bytes)} | ${code(p.via.join(" › "))} |`, + ); + out.push(""); + }; + refs("Added dependencies", row.added); + refs("Removed dependencies", row.removed); + refs("Largest dependencies", row.largest); + + if (row.installScriptsAdded.length > 0) { + out.push( + `**New install scripts:** ${row.installScriptsAdded.map(code).join(", ")}`, + "", + ); + } + if (row.floating.length > 0) { + out.push( + "**Floating `0.x` ranges** (resolve above their floor today)", + "", + "| range | declared by | resolves to | subtree |", + "|---|---|---|---:|", + ); + for (const f of row.floating) { + out.push( + `| ${code(`${f.dependency}@${f.range}`)} | ${code(f.declaredBy)} | ${code(f.resolved)} | ${bytes(f.subtreeBytes)} |`, + ); + } + out.push(""); + } + const entries = row.entrypoints.filter( + (e) => e.severity !== "ok" || hasSurfaceChange(e), + ); + if (entries.length > 0) { + out.push( + "**Entrypoints** (own code, minified; third-party imports excluded)", + "", + "| entrypoint | base | this PR | Δ | imports | exports |", + "|---|---:|---:|---|---|---|", + ); + for (const e of entries) { + out.push( + `| ${ICON[e.severity]}${code(e.subpath)} | ${e.base === undefined ? "—" : bytes(e.base)} | ${e.missing ? "❌ file not shipped" : e.pr === undefined ? "—" : bytes(e.pr)} | ${e.base !== undefined && e.pr !== undefined ? change(e.base, e.pr) : "new"} | ${diffList(e.externalsAdded, e.externalsRemoved)} | ${diffList(e.exportsAdded, e.exportsRemoved)} |`, + ); + } + out.push(""); + } + out.push("
", ""); + return out; +} + +const hasSurfaceChange = (e: EntrypointRow) => + e.externalsAdded.length + + e.externalsRemoved.length + + e.exportsAdded.length + + e.exportsRemoved.length > + 0; + +function diffList( + added: ReadonlyArray, + removed: ReadonlyArray, +): string { + const parts = [ + ...added.map((a) => `+${code(a)}`), + ...removed.map((r) => `−${code(r)}`), + ]; + return parts.length === 0 ? "—" : parts.join(" "); +} diff --git a/scripts/pipboy/src/render/format.ts b/scripts/pipboy/src/render/format.ts new file mode 100644 index 0000000000..79ca03ef31 --- /dev/null +++ b/scripts/pipboy/src/render/format.ts @@ -0,0 +1,47 @@ +import type { Severity } from "../compare/footprint.ts"; + +const UNITS = ["B", "KB", "MB", "GB"]; + +export function bytes(n: number): string { + let value = Math.abs(n); + let unit = 0; + while (value >= 1024 && unit < UNITS.length - 1) { + value /= 1024; + unit++; + } + const digits = unit >= 3 ? 2 : unit >= 1 ? 1 : 0; + return `${n < 0 ? "−" : ""}${value.toFixed(digits)} ${UNITS[unit]}`; +} + +/** `+1.40 GB (+454.0%)`, or `0` when nothing changed. */ +export function change( + before: number, + after: number, + format: (n: number) => string = bytes, +): string { + const delta = after - before; + if (delta === 0) return "0"; + const sign = delta > 0 ? "+" : "−"; + const pct = + before === 0 + ? "new" + : `${sign}${Math.abs((delta / before) * 100).toFixed(1)}%`; + return `${sign}${format(Math.abs(delta))} (${pct})`; +} + +export const count = (n: number) => String(n); + +export const ICON: Record = { + ok: "", + warn: "🟡 ", + alert: "🔴 ", + fail: "❌ ", +}; + +/** Inline code for untrusted text: backticks can't close the span, and pipes can't break a table. */ +export function code(text: string): string { + return `\`${text + .replace(/`/g, "'") + .replace(/\|/g, "\\|") + .replace(/[\r\n]+/g, " ")}\``; +} diff --git a/scripts/pipboy/src/render/hygiene.ts b/scripts/pipboy/src/render/hygiene.ts new file mode 100644 index 0000000000..a131fe2569 --- /dev/null +++ b/scripts/pipboy/src/render/hygiene.ts @@ -0,0 +1,37 @@ +import { BLOCKING, type HygieneComparison } from "../compare/hygiene.ts"; +import { code } from "./format.ts"; + +const MAX_LISTED = 20; + +export function renderHygiene(c: HygieneComparison): string { + const out = [ + "### 🧹 Rust dependencies · `cargo deny` + `cargo shear`", + "", + "| check | new in this PR | fixed | existing on base |", + "|---|---|---:|---:|", + ]; + for (const row of c.rows) { + const icon = + row.added.length === 0 ? "✅" : BLOCKING.has(row.check) ? "❌" : "🟡"; + out.push( + `| ${row.check} | ${icon} ${row.added.length} | ${row.fixed} | ${row.existing} |`, + ); + } + if (!c.hasBase) + out.push( + "", + "No base to compare against: every finding counts as existing.", + ); + + const added = c.rows.flatMap((row) => + row.added.map((f) => ({ ...f, check: row.check })), + ); + if (added.length > 0) { + out.push("", "| new finding | via |", "|---|---|"); + for (const f of added.slice(0, MAX_LISTED)) + out.push(`| ${code(f.title)} | ${code(f.via)} |`); + if (added.length > MAX_LISTED) + out.push(`| … and ${added.length - MAX_LISTED} more | |`); + } + return out.join("\n"); +} diff --git a/scripts/pipboy/src/render/native.ts b/scripts/pipboy/src/render/native.ts new file mode 100644 index 0000000000..8c2dc48b06 --- /dev/null +++ b/scripts/pipboy/src/render/native.ts @@ -0,0 +1,51 @@ +import { Option } from "effect"; +import type { NativeRow } from "../compare/native.ts"; +import { bytes, change, code, ICON } from "./format.ts"; + +/** `baseline` is the main commit the base sizes came from, if any. */ +export function renderNative( + rows: ReadonlyArray, + baseline: Option.Option<{ + readonly sha: string; + readonly isMergeBase: boolean; + }>, +): string { + const source = Option.match(baseline, { + onNone: () => "No native build on main to compare against yet.", + onSome: ({ sha, isMergeBase }) => + isMergeBase + ? `Base: \`main@${sha.slice(0, 7)}\`, the merge base.` + : `Base: \`main@${sha.slice(0, 7)}\`, the newest main commit with native builds before the merge base. Size changes merged to main since then show up here too.`, + }); + const out = [ + "### 🦀 Binaries · publish Dockerfiles, `--release`", + "", + source, + "", + "| artifact | npm `@latest` | base | this PR | Δ vs base |", + "|---|---:|---:|---:|---|", + ]; + for (const row of rows) { + out.push( + `| ${ICON[row.severity]}${code(`${row.target} ${row.platform}`)} | ${row.latest === undefined ? "—" : bytes(row.latest)} | ${row.base === undefined ? "—" : bytes(row.base)} | ${bytes(row.pr)} | ${row.base === undefined ? "—" : change(row.base, row.pr)} |`, + ); + } + for (const row of rows.filter( + (r) => r.crates.length > 0 && r.severity !== "ok", + )) { + out.push( + "", + `
${code(`${row.target} ${row.platform}`)} · crates that changed most (from symbol sizes, approximate)`, + "", + "| crate | base | this PR | Δ |", + "|---|---:|---:|---|", + ...row.crates.map( + (c) => + `| ${code(c.name)} | ${bytes(c.base)} | ${bytes(c.pr)} | ${change(c.base, c.pr)} |`, + ), + "", + "
", + ); + } + return out.join("\n"); +} diff --git a/scripts/pipboy/src/render/status.ts b/scripts/pipboy/src/render/status.ts new file mode 100644 index 0000000000..61c125efe7 --- /dev/null +++ b/scripts/pipboy/src/render/status.ts @@ -0,0 +1,85 @@ +import { Schema } from "effect"; + +const JobResult = Schema.Literals([ + "success", + "failure", + "cancelled", + "skipped", +]); + +/** `toJSON(needs)` from the workflow: each needed job's result. */ +export const JobResults = Schema.Record( + Schema.String, + Schema.Struct({ result: JobResult }), +); +export type JobResults = Schema.Schema.Type; + +const PAST: Record, string> = { + success: "succeeded", + failure: "failed", + cancelled: "was cancelled", + skipped: "was skipped", +}; + +export interface CheckSection { + /** Section heading, such as `### 📦 Packages`. */ + readonly heading: string; + /** Workflow job that produces the report. */ + readonly job: string; + /** Why the job is skipped when nothing relevant changed, completing "Skipped: …". */ + readonly skipReason: string; + /** The rendered report, when the job produced one. Starts with its own heading. */ + readonly rendered: string | undefined; +} + +/** + * The comment section for one check, from the job's actual result. A missing + * report is never read as "skipped": a failed job says it failed and links the + * run, even when it wrote no report. + */ +export function renderCheckSection( + section: CheckSection, + results: JobResults, + runUrl: string, +): string { + const run = `See the [run](${runUrl}).`; + const say = (line: string) => `${section.heading}\n\n${line}`; + // Every check depends on `changes`, so a check skipped after `changes` failed never had a chance to run. + const changes = results.changes?.result; + const result = results[section.job]?.result; + if (result === undefined) + return say( + `❌ The \`${section.job}\` job is missing from the workflow's \`needs\`. ${run}`, + ); + + switch (result) { + case "skipped": + return changes === "success" + ? say(`Skipped: ${section.skipReason}`) + : say( + `❌ Not run: the \`changes\` job ${changes === undefined ? "is missing" : PAST[changes]}. ${run}`, + ); + case "cancelled": + return say(`⊘ The \`${section.job}\` job was cancelled.`); + case "failure": { + const failed = `❌ The \`${section.job}\` job failed. ${run}`; + return section.rendered === undefined + ? say(`${failed} It produced no report.`) + : insertAfterHeading(section.rendered, failed); + } + case "success": + return ( + section.rendered ?? + say( + `❌ The \`${section.job}\` job succeeded but produced no report. ${run}`, + ) + ); + } +} + +function insertAfterHeading(rendered: string, line: string): string { + const end = rendered.indexOf("\n"); + return end === -1 + ? `${rendered}\n\n${line}` + : `${rendered.slice(0, end)}\n\n${line}${rendered.slice(end)}`; +} diff --git a/scripts/pipboy/src/render/trace.ts b/scripts/pipboy/src/render/trace.ts new file mode 100644 index 0000000000..e6e5ba3bfc --- /dev/null +++ b/scripts/pipboy/src/render/trace.ts @@ -0,0 +1,19 @@ +/** Collapsed comment section: how a maintainer or agent pulls the full CI trace for `sha`. */ +export function renderTraceHelp(sha: string): string { + return [ + "
🔭 Full CI trace for this commit", + "", + `One OpenTelemetry trace of CI and Pip-Boy for \`${sha.slice(0, 7)}\`: every job and step, with the sizes above attached. Run after CI finishes.`, + "", + "```bash", + "pnpm install --frozen-lockfile --filter 'pipboy...'", + `pnpm --filter pipboy pipboy trace --sha ${sha}`, + "```", + "", + "Add `--otlp ` to send it to any OTLP/HTTP collector.", + "", + "**Agent instruction:** From a `rivet-dev/agentos` checkout, run the commands above, then read the trace file the last command prints. List the slowest jobs and steps and explain where CI time went.", + "", + "
", + ].join("\n"); +} diff --git a/scripts/pipboy/src/schema/footprint.ts b/scripts/pipboy/src/schema/footprint.ts new file mode 100644 index 0000000000..eee09c1e02 --- /dev/null +++ b/scripts/pipboy/src/schema/footprint.ts @@ -0,0 +1,65 @@ +import { Schema } from "effect"; + +/** One resolved package in a side's dependency graph, keyed by its lockfile path. */ +export const PackageNode = Schema.Struct({ + name: Schema.String, + version: Schema.String, + /** Unpacked size in bytes. */ + bytes: Schema.Number, + os: Schema.optionalKey(Schema.Array(Schema.String)), + cpu: Schema.optionalKey(Schema.Array(Schema.String)), + libc: Schema.optionalKey(Schema.Array(Schema.String)), + installScript: Schema.Boolean, + /** Lockfile keys of the packages this one depends on. */ + deps: Schema.Array(Schema.String), + /** Declared `^0.x` ranges, by dependency name. */ + zeroXRanges: Schema.Record(Schema.String, Schema.String), +}); +export interface PackageNode extends Schema.Schema.Type {} + +/** Measurements of one entrypoint of a package we publish. */ +export const Entrypoint = Schema.Struct({ + /** Minified bytes of our own code, third-party imports excluded. */ + bytes: Schema.Number, + /** Third-party packages the entrypoint imports. */ + externals: Schema.Array(Schema.String), + /** Exported names from the entrypoint's type declarations. */ + exports: Schema.Array(Schema.String), + /** The entrypoint points to a file the package doesn't ship, so importing it fails. */ + missing: Schema.optionalKey(Schema.Boolean), +}); +export interface Entrypoint extends Schema.Schema.Type {} + +export const SideName = Schema.Literals(["latest", "base", "pr"]); +export type SideName = Schema.Schema.Type; + +export const FootprintSide = Schema.Struct({ + side: SideName, + npmVersion: Schema.String, + /** Root package name to its lockfile key. */ + roots: Schema.Record(Schema.String, Schema.String), + nodes: Schema.Record(Schema.String, PackageNode), + /** Our packages' entrypoints by package name, then export subpath. Empty for `latest`. */ + entrypoints: Schema.Record( + Schema.String, + Schema.Record(Schema.String, Entrypoint), + ), +}); +export interface FootprintSide + extends Schema.Schema.Type {} + +export const FootprintReport = Schema.Struct({ + schema: Schema.Literal(1), + check: Schema.Literal("footprint"), + sides: Schema.Array(FootprintSide), +}); +export interface FootprintReport + extends Schema.Schema.Type {} + +/** Written by `pack`, read by `footprint`. */ +export const PackManifest = Schema.Struct({ + version: Schema.String, + /** Package name to tarball file name, relative to the manifest. */ + tarballs: Schema.Record(Schema.String, Schema.String), +}); +export interface PackManifest extends Schema.Schema.Type {} diff --git a/scripts/pipboy/src/schema/hygiene.ts b/scripts/pipboy/src/schema/hygiene.ts new file mode 100644 index 0000000000..ce38e5e743 --- /dev/null +++ b/scripts/pipboy/src/schema/hygiene.ts @@ -0,0 +1,35 @@ +import { Schema } from "effect"; + +export const HygieneCheck = Schema.Literals([ + "advisories", + "licenses", + "sources", + "bans", + "unused", +]); +export type HygieneCheck = Schema.Schema.Type; + +export const HygieneFinding = Schema.Struct({ + check: HygieneCheck, + /** Stable identity across commits, such as `vulnerability:RUSTSEC-2026-0007:bytes@1.10.1`. */ + key: Schema.String, + title: Schema.String, + /** Crate names from a workspace crate down to the offending one, or the manifest path for unused deps. */ + via: Schema.String, +}); +export interface HygieneFinding + extends Schema.Schema.Type {} + +export const HygieneSide = Schema.Struct({ + side: Schema.Literals(["base", "pr"]), + findings: Schema.Array(HygieneFinding), +}); +export interface HygieneSide extends Schema.Schema.Type {} + +export const HygieneReport = Schema.Struct({ + schema: Schema.Literal(1), + check: Schema.Literal("hygiene"), + sides: Schema.Array(HygieneSide), +}); +export interface HygieneReport + extends Schema.Schema.Type {} diff --git a/scripts/pipboy/src/schema/native.ts b/scripts/pipboy/src/schema/native.ts new file mode 100644 index 0000000000..f25d4fa76b --- /dev/null +++ b/scripts/pipboy/src/schema/native.ts @@ -0,0 +1,22 @@ +import { Schema } from "effect"; + +export const NativeArtifact = Schema.Struct({ + /** `agentos-native-sidecar`. */ + target: Schema.String, + platform: Schema.String, + /** Size of the file we publish. */ + bytes: Schema.Number, + /** Symbol bytes by crate, from the binary's symbol table. Empty when the binary has no symbols. */ + crates: Schema.Record(Schema.String, Schema.Number), +}); +export interface NativeArtifact + extends Schema.Schema.Type {} + +export const NativeReport = Schema.Struct({ + schema: Schema.Literal(1), + check: Schema.Literal("native"), + artifact: NativeArtifact, + /** Unpacked size of the published npm platform package, for reference. */ + latestBytes: Schema.optionalKey(Schema.Number), +}); +export interface NativeReport extends Schema.Schema.Type {} diff --git a/scripts/pipboy/src/trace/commit.ts b/scripts/pipboy/src/trace/commit.ts new file mode 100644 index 0000000000..24b8c2c3d4 --- /dev/null +++ b/scripts/pipboy/src/trace/commit.ts @@ -0,0 +1,248 @@ +import { join } from "node:path"; +import { Effect, FileSystem, Schema } from "effect"; +import { compareFootprint } from "../compare/footprint.ts"; +import { compareHygiene } from "../compare/hygiene.ts"; +import { run } from "../exec.ts"; +import { GitHub } from "../github/api.ts"; +import { WORKFLOW_PATH } from "../github/baseline.ts"; +import { readJobs } from "../github/timing.ts"; +import { FootprintReport } from "../schema/footprint.ts"; +import { HygieneReport } from "../schema/hygiene.ts"; +import { NativeReport } from "../schema/native.ts"; +import { + type Attribute, + attr, + jobSpans, + otlpDocument, + runSpanId, + span, + toMillis, + traceIdFor, +} from "./spans.ts"; + +const WorkflowRun = Schema.Struct({ + id: Schema.Number, + name: Schema.NullOr(Schema.String), + path: Schema.String, + event: Schema.String, + status: Schema.NullOr(Schema.String), + conclusion: Schema.NullOr(Schema.String), + run_attempt: Schema.Number, + created_at: Schema.String, + run_started_at: Schema.optionalKey(Schema.NullOr(Schema.String)), + updated_at: Schema.String, + html_url: Schema.String, +}); +type WorkflowRun = Schema.Schema.Type; + +const Runs = Schema.Struct({ + total_count: Schema.Number, + workflow_runs: Schema.Array(WorkflowRun), +}); +const Artifacts = Schema.Struct({ + artifacts: Schema.Array( + Schema.Struct({ name: Schema.String, expired: Schema.Boolean }), + ), +}); + +/** + * The workflows that build and test the code, by file. Automation such as docs + * sync is left out of the trace. + */ +const TRACED_WORKFLOWS = new Set([ + ".github/workflows/ci.yml", + WORKFLOW_PATH, + ".github/workflows/publish.yaml", + ".github/workflows/bench.yml", +]); + +/** Every workflow run GitHub started for `sha`, across all workflows and events. */ +const listRuns = Effect.fn("CommitTrace.listRuns")(function* (sha: string) { + const github = yield* GitHub; + const runs: Array = []; + for (let page = 1; ; page++) { + const batch = yield* github.get( + `/repos/${github.repository}/actions/runs?head_sha=${sha}&per_page=100&page=${page}`, + Runs, + ); + runs.push(...batch.workflow_runs); + if (batch.workflow_runs.length < 100 || runs.length >= batch.total_count) + return runs; + } +}); + +/** + * Span attributes from a Pip-Boy run's reports, keyed by job name, so a + * job's span carries the numbers it measured. + */ +const reportAttributes = Effect.fn("CommitTrace.reportAttributes")(function* ( + runId: number, +) { + const github = yield* GitHub; + const fs = yield* FileSystem.FileSystem; + const byJob = new Map>(); + const add = (job: string, attributes: ReadonlyArray) => + byJob.set(job, [...(byJob.get(job) ?? []), ...attributes]); + + const { artifacts } = yield* github.get( + `/repos/${github.repository}/actions/runs/${runId}/artifacts?per_page=100`, + Artifacts, + ); + const reports = artifacts.filter( + (a) => a.name.startsWith("report-") && !a.expired, + ); + if (reports.length === 0) return byJob; + const dir = yield* fs.makeTempDirectoryScoped({ + prefix: "pipboy-trace-", + }); + yield* run("gh", [ + "run", + "download", + String(runId), + "--repo", + github.repository, + "--dir", + dir, + ...reports.flatMap((a) => ["--name", a.name]), + ]); + + const read = (path: string, schema: S) => + fs + .readFileString(path) + .pipe( + Effect.flatMap( + Schema.decodeUnknownEffect(Schema.fromJsonString(schema)), + ), + ); + for (const artifact of reports) { + const artifactDir = reports.length === 1 ? dir : join(dir, artifact.name); + for (const file of yield* fs.readDirectory(artifactDir)) { + const path = join(artifactDir, file); + if (file === "footprint.json") { + const comparison = compareFootprint(yield* read(path, FootprintReport)); + for (const row of comparison.rows) { + const pr = row.install[0]?.pr; + if (!pr) continue; + add("footprint", [ + attr(`rivet.footprint.install_bytes.${row.name}`, pr.bytes), + attr(`rivet.footprint.packages.${row.name}`, pr.count), + ]); + } + } else if (file === "hygiene.json") { + for (const row of compareHygiene(yield* read(path, HygieneReport)) + .rows) { + add("hygiene", [ + attr(`rivet.hygiene.${row.check}.new`, row.added.length), + attr(`rivet.hygiene.${row.check}.existing`, row.existing), + ]); + } + } else if (/^native-.+\.json$/.test(file)) { + const { artifact: a } = yield* read(path, NativeReport); + add(`native · ${a.target} · ${a.platform}`, [ + attr("rivet.native.bytes", a.bytes), + ]); + } + } + } + return byJob; +}); + +export class NoRunsForCommit extends Schema.TaggedError()( + "NoRunsForCommit", + { + sha: Schema.String, + repository: Schema.String, + }, +) { + override get message() { + return `no workflow runs found for ${this.sha} in ${this.repository}`; + } +} + +/** + * One trace for the CI that ran on a commit: commit, then each traced workflow + * run, its jobs, and their steps. Pip-Boy jobs also carry the sizes and + * findings they measured. + */ +export const commitTrace = Effect.fn("CommitTrace.build")(function* ( + sha: string, +) { + const github = yield* GitHub; + const now = Date.now(); + const traceId = traceIdFor(github.repository, sha); + // A push can start the same workflow twice for one commit; the newer run cancels the older. + // Only the newest run of each traced workflow is kept. + const newest = new Map(); + for (const r of yield* listRuns(sha)) { + if (!TRACED_WORKFLOWS.has(r.path)) continue; + const kept = newest.get(r.path); + if (kept === undefined || r.id > kept.id) newest.set(r.path, r); + } + const runs = [...newest.values()]; + if (runs.length === 0) + return yield* new NoRunsForCommit({ sha, repository: github.repository }); + + const perRun = yield* Effect.forEach( + runs, + (r) => + Effect.all({ + jobs: readJobs(r.id, r.run_attempt), + attributes: + r.path === WORKFLOW_PATH + ? reportAttributes(r.id) + : Effect.succeed(new Map>()), + }).pipe(Effect.map((details) => ({ run: r, ...details }))), + { concurrency: 8 }, + ); + + const rootId = runSpanId(`commit:${sha}`); + const runStart = (r: WorkflowRun) => + toMillis(r.run_started_at, toMillis(r.created_at, now)); + const runEnd = (r: WorkflowRun) => + r.status === "completed" ? toMillis(r.updated_at, now) : now; + const spans = [ + span({ + traceId, + id: rootId, + name: `commit ${sha.slice(0, 7)}`, + startMs: Math.min(...runs.map(runStart)), + endMs: Math.max(...runs.map(runEnd)), + failed: runs.some((r) => r.conclusion === "failure"), + attributes: [ + attr("vcs.ref.head.revision", sha), + attr("vcs.repository.name", github.repository), + attr("cicd.pipeline.run.count", runs.length), + ], + }), + ]; + for (const { run: r, jobs, attributes } of perRun) { + const id = runSpanId(r.id); + spans.push( + span({ + traceId, + id, + parent: rootId, + name: r.name ?? r.path, + startMs: runStart(r), + endMs: runEnd(r), + failed: r.conclusion === "failure", + attributes: [ + attr("cicd.pipeline.name", r.name ?? r.path), + attr("cicd.pipeline.run.id", r.id), + attr("cicd.pipeline.run.url.full", r.html_url), + attr("cicd.pipeline.result", r.conclusion ?? r.status ?? "unknown"), + attr("cicd.pipeline.trigger.event", r.event), + attr("cicd.pipeline.run.attempt", r.run_attempt), + ], + }), + ...jobSpans( + traceId, + id, + jobs, + now, + (job) => attributes.get(job.name) ?? [], + ), + ); + } + return { runs: runs.length, document: otlpDocument(spans) }; +}); diff --git a/scripts/pipboy/src/trace/spans.ts b/scripts/pipboy/src/trace/spans.ts new file mode 100644 index 0000000000..b57ee5bf12 --- /dev/null +++ b/scripts/pipboy/src/trace/spans.ts @@ -0,0 +1,163 @@ +import { createHash } from "node:crypto"; + +export interface Step { + readonly name: string; + readonly number: number; + readonly conclusion: string | null; + readonly started_at: string | null; + readonly completed_at: string | null; +} + +export interface Job { + readonly id: number; + readonly name: string; + readonly conclusion: string | null; + readonly created_at?: string; + readonly started_at: string | null; + readonly completed_at: string | null; + readonly steps?: ReadonlyArray; +} + +export interface Attribute { + readonly key: string; + readonly value: + | { readonly stringValue: string } + | { readonly intValue: string }; +} + +export interface Span { + readonly traceId: string; + readonly spanId: string; + readonly parentSpanId?: string; + readonly name: string; + readonly kind: 1; + readonly startTimeUnixNano: string; + readonly endTimeUnixNano: string; + readonly attributes: ReadonlyArray; + readonly status: { readonly code: 1 | 2 }; +} + +const hex = (input: string, length: number) => + createHash("sha256").update(input).digest("hex").slice(0, length); + +/** One trace per commit: every workflow run for the commit lands in it, from any tool. */ +export const traceIdFor = (repository: string, sha: string) => + hex(`${repository}:${sha}`, 32); +export const runSpanId = (runId: number | string) => hex(`run:${runId}`, 16); + +export const attr = (key: string, value: string | number): Attribute => + typeof value === "number" + ? { key, value: { intValue: String(Math.round(value)) } } + : { key, value: { stringValue: value } }; + +/** + * Short step names: `Run actions/checkout@` becomes `checkout`. Rendered + * as code in comments, so GitHub never turns `owner/repo@sha` into a commit + * link or `@name` into a mention. + */ +export function stepLabel(name: string): string { + const action = name.match( + /^(Post )?Run [\w.-]+\/([\w.-]+)(?:\/[\w./-]+)?@\S+$/, + ); + if (action) return `${action[1] ? "post " : ""}${action[2]}`; + return name.split("\n")[0] ?? name; +} + +export const toMillis = (iso: string | null | undefined, fallback: number) => + iso === null || iso === undefined ? fallback : Date.parse(iso); + +export function span(fields: { + traceId: string; + id: string; + parent?: string; + name: string; + startMs: number; + endMs: number; + failed?: boolean; + attributes?: ReadonlyArray; +}): Span { + const nanos = (ms: number) => `${BigInt(Math.round(ms)) * 1_000_000n}`; + return { + traceId: fields.traceId, + spanId: fields.id, + ...(fields.parent ? { parentSpanId: fields.parent } : {}), + name: fields.name, + kind: 1, + startTimeUnixNano: nanos(fields.startMs), + endTimeUnixNano: nanos(Math.max(fields.startMs, fields.endMs)), + attributes: fields.attributes ?? [], + status: { code: fields.failed ? 2 : 1 }, + }; +} + +/** Job and step spans under `parent`. Running jobs and steps end at `now`. */ +export function jobSpans( + traceId: string, + parent: string, + jobs: ReadonlyArray, + now: number, + extraAttributes: (job: Job) => ReadonlyArray = () => [], +): Array { + const spans: Array = []; + for (const job of jobs) { + if (job.started_at === null) continue; + const jobId = hex(`job:${job.id}`, 16); + const start = toMillis(job.started_at, now); + spans.push( + span({ + traceId, + id: jobId, + parent, + name: job.name, + startMs: start, + endMs: toMillis(job.completed_at, now), + failed: job.conclusion === "failure", + attributes: [ + attr("cicd.pipeline.task.name", job.name), + attr("cicd.pipeline.result", job.conclusion ?? "running"), + // Time spent waiting for a runner before the job started. + ...(job.created_at + ? [ + attr( + "cicd.pipeline.task.queued_ms", + start - toMillis(job.created_at, start), + ), + ] + : []), + ...extraAttributes(job), + ], + }), + ); + for (const step of job.steps ?? []) { + if (step.started_at === null) continue; + spans.push( + span({ + traceId, + id: hex(`step:${job.id}:${step.number}`, 16), + parent: jobId, + name: stepLabel(step.name), + startMs: toMillis(step.started_at, now), + endMs: toMillis(step.completed_at, now), + failed: step.conclusion === "failure", + attributes: [ + attr("cicd.pipeline.task.step.name", step.name), + attr("cicd.pipeline.result", step.conclusion ?? "running"), + ], + }), + ); + } + } + return spans; +} + +/** An OTLP/JSON trace export, as accepted by `POST /v1/traces`. */ +export function otlpDocument(spans: ReadonlyArray) { + return { + resourceSpans: [ + { + resource: { attributes: [attr("service.name", "rivet-ci")] }, + scopeSpans: [{ scope: { name: "pipboy" }, spans }], + }, + ], + }; +} diff --git a/scripts/pipboy/tests/footprint.e2e.test.ts b/scripts/pipboy/tests/footprint.e2e.test.ts new file mode 100644 index 0000000000..c9af17a627 --- /dev/null +++ b/scripts/pipboy/tests/footprint.e2e.test.ts @@ -0,0 +1,213 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +const run = promisify(execFile); +const CLI = join(import.meta.dirname, "../src/cli.ts"); +const MB = 1024 ** 2; + +interface Published { + readonly manifest: Record; + readonly tarball: Buffer; +} + +/** A minimal npm registry: packuments and tarballs over real HTTP, for the real npm client. */ +class Registry { + private readonly packages = new Map>(); + private server: Server | undefined; + url = ""; + + async start() { + this.server = createServer((req, res) => { + const path = decodeURIComponent(req.url ?? "/").slice(1); + const tarball = path.match(/^(.+)\/-\/(.+)\.tgz$/); + if (tarball) { + const [, name, file] = tarball; + const version = file?.slice(file.lastIndexOf("-") + 1); + const published = this.packages.get(name ?? "")?.get(version ?? ""); + if (!published) return res.writeHead(404).end(); + return res + .writeHead(200, { "content-type": "application/octet-stream" }) + .end(published.tarball); + } + // `name` or `name/version`, where a scoped name is `@scope/name`. + const [, name, version] = path.match(/^((?:@[^/]+\/)?[^/]+)(?:\/(.+))?$/) ?? []; + const versions = this.packages.get(name ?? ""); + if (!versions) return res.writeHead(404).end("{}"); + const body = + version === undefined + ? { + name, + "dist-tags": { latest: [...versions.keys()].at(-1) }, + versions: Object.fromEntries( + [...versions].map(([v, p]) => [v, p.manifest]), + ), + } + : versions.get(version)?.manifest; + res + .writeHead(body ? 200 : 404, { "content-type": "application/json" }) + .end(JSON.stringify(body ?? {})); + }); + await new Promise((resolve) => + this.server?.listen(0, "127.0.0.1", resolve), + ); + this.url = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}/`; + } + + stop() { + this.server?.close(); + } + + /** Publishes a package. `unpackedSize` is what the registry reports, which is all the check reads. */ + async publish( + name: string, + version: string, + dependencies: Record, + unpackedSize: number, + ) { + const tarball = await packFixture(name, version, dependencies); + const manifest = { + name, + version, + dependencies, + dist: { + tarball: `${this.url}${name}/-/${name}-${version}.tgz`, + shasum: createHash("sha1").update(tarball).digest("hex"), + integrity: `sha512-${createHash("sha512").update(tarball).digest("base64")}`, + unpackedSize, + }, + }; + const versions = this.packages.get(name) ?? new Map(); + versions.set(version, { manifest, tarball }); + this.packages.set(name, versions); + } +} + +async function packFixture( + name: string, + version: string, + dependencies: Record, +): Promise { + const dir = mkdtempSync(join(tmpdir(), "pipboy-fixture-")); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name, version, main: "index.js", dependencies }), + ); + writeFileSync( + join(dir, "index.js"), + `export const name = ${JSON.stringify(name)};\n`, + ); + await run("npm", ["pack", "--pack-destination", dir], { cwd: dir }); + const file = readdirSync(dir).find((f) => f.endsWith(".tgz")) ?? ""; + const tarball = readFileSync(join(dir, file)); + rmSync(dir, { recursive: true, force: true }); + return tarball; +} + +/** A `pack` output directory holding one workspace package, as CI produces for a commit. */ +async function packedSide( + dependencies: Record, +): Promise { + const dir = mkdtempSync(join(tmpdir(), "pipboy-side-")); + const file = "rivet-dev-agentos-core-0.0.0-pipboy.tgz"; + writeFileSync( + join(dir, file), + await packFixture("@rivet-dev/agentos-core", "0.0.0-pipboy", dependencies), + ); + writeFileSync( + join(dir, "manifest.json"), + JSON.stringify({ + version: "0.0.0-pipboy", + tarballs: { "@rivet-dev/agentos-core": file }, + }), + ); + return dir; +} + +async function footprint(registry: Registry, pr: string, base: string) { + const out = join( + mkdtempSync(join(tmpdir(), "pipboy-out-")), + "footprint.json", + ); + const env = { + ...process.env, + npm_config_registry: registry.url, + npm_config_cache: join(tmpdir(), `pipboy-npm-cache-${process.pid}`), + }; + try { + const { stdout } = await run( + process.execPath, + [ + "--import", + "tsx", + CLI, + "footprint", + "--pr", + pr, + "--base", + base, + "--out", + out, + ], + { env }, + ); + return { exitCode: 0, output: stdout }; + } catch (error) { + const e = error as { code: number; stdout: string; stderr: string }; + return { exitCode: e.code, output: `${e.stdout}\n${e.stderr}` }; + } +} + +describe("footprint", () => { + // A fresh registry per test, so each test publishes exactly what it needs. + let registry: Registry; + beforeEach(async () => { + registry = new Registry(); + await registry.start(); + }); + afterEach(() => registry.stop()); + + test("fails when the PR itself pushes a package over its budget, and names the dependency", async () => { + await registry.publish("dep", "0.2.1", {}, 1 * MB); + await registry.publish("heavy", "1.0.0", {}, 600 * MB); + await registry.publish("@rivet-dev/agentos-core", "1.0.0", { dep: "^0.2.1" }, 5 * MB); + + const base = await packedSide({ dep: "^0.2.1" }); + const pr = await packedSide({ dep: "^0.2.1", heavy: "1.0.0" }); + const result = await footprint(registry, pr, base); + + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain("over its"); + expect(result.output).toContain("`heavy@1.0.0`"); + expect(result.output).toContain("`@rivet-dev/agentos-core › heavy`"); + }, 120_000); + + test("reports registry drift without failing an unchanged PR, and traces it to the floating 0.x range", async () => { + await registry.publish("dep", "0.2.1", {}, 1 * MB); + await registry.publish("heavy", "1.0.0", {}, 600 * MB); + await registry.publish("@rivet-dev/agentos-core", "1.0.0", { dep: "^0.2.1" }, 5 * MB); + // September 2026: a patch inside `^0.2.1` starts pulling in a huge package. + await registry.publish("dep", "0.2.20", { heavy: "1.0.0" }, 1 * MB); + + const side = await packedSide({ dep: "^0.2.1" }); + const result = await footprint(registry, side, side); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("Registry drift"); + expect(result.output).toContain("`@rivet-dev/agentos-core › dep › heavy`"); + expect(result.output).toContain("`dep@^0.2.1`"); + expect(result.output).toContain("`0.2.20`"); + }, 120_000); +}); diff --git a/scripts/pipboy/tsconfig.json b/scripts/pipboy/tsconfig.json new file mode 100644 index 0000000000..ba4032bdbf --- /dev/null +++ b/scripts/pipboy/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"], + "exclude": ["node_modules"] +}