name: ci

on:
  push:
    branches: [main, dev-freeform]
  pull_request:

# Cancel superseded runs on the same ref.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ---- Rust build + test + lint (cross-platform: exercises both the
  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
  #
  # CI-SELFHOST-PLAN.md: fully self-hosted (2026-06-03) — hfenduleam carries
  # the Windows leg, kitsubito the Linux leg + traceability (replaced
  # gravity-linux 2026-06-07; same Ubuntu 22.04 / glibc 2.35 baseline). Free
  # minutes;
  # persistent cargo/target = warm builds (the multi-exe iroh link cost is
  # paid once per machine); hung tests debuggable on-box. Both boxes own
  # their rust toolchains (rustup-managed); no toolchain action, no
  # rust-cache (the persistent target dir IS the cache).
  test:
    strategy:
      fail-fast: false
      matrix:
        runner: [[self-hosted, Linux, kitsubito], [self-hosted, Windows, hfenduleam]]
    runs-on: ${{ matrix.runner }}
    # A wedged test must not block the single self-hosted runner slot (the
    # 2026-06-03 handoff.rs ConPTY stall burned 22 hosted minutes unbounded).
    # 50 (was 25): the slow Linux runner (kitsubito) needs more than 25min for a
    # full build + the whole suite (the int tests carry fixed wall-clock sleeps);
    # a version-bump full rebuild pushed v0.7.0 over. The real fix is the build
    # speedup (mold/nextest — ci-kitsubito-speedup-plan); this keeps the wedge
    # guard generous, not unbounded.
    timeout-minutes: 50
    steps:
      - uses: actions/checkout@v5
      # Linux: link with mold (gcc -fuse-ld=mold). The slow part of a Linux
      # build is serial linking of the many test/bin targets; mold parallelizes
      # it. Linux-only + CI-only (set into the job env, never committed to
      # .cargo/config), so Windows (MSVC linker) and Linux devs without mold are
      # untouched. See ci-kitsubito-speedup-plan.
      - name: Use mold linker (Linux)
        if: runner.os == 'Linux'
        run: echo 'RUSTFLAGS=-C link-arg=-fuse-ld=mold' >> "$GITHUB_ENV"
      # `cargo nextest` runs every test across ALL test binaries in ONE global
      # parallel pool — `cargo test` runs the binaries SERIALLY (only threads
      # WITHIN a binary), so on a 16-core box the many integration-test binaries
      # (with their fixed wall-clock sleeps) sat queued behind each other. nextest
      # also builds the test targets, so the old standalone `cargo build
      # --all-targets` is dropped (clippy --all-targets below still compiles every
      # target — examples/benches included — so non-test breakage is still caught).
      - name: Test (nextest)
        run: cargo nextest run --workspace
      # nextest does not run doctests — keep them on `cargo test --doc`.
      - name: Doctests
        run: cargo test --workspace --doc
      - name: Clippy (deny warnings)
        run: cargo clippy --workspace --all-targets -- -D warnings

      # ---- M5-D8a real-shell dogfood E2E (thin cross-repo hook) ----
      # The notify adapter lives in its own repo (SaberMage/spt-shell-notify,
      # public): the manifest + binary are the ONLY glue — zero spt-core
      # source integration. CI clones + builds it, then re-runs the
      # env-gated E2E leg against the real binary (the normal `cargo test`
      # sweep silent-skips the same test when SPT_NOTIFY_SHELL_BIN is unset).
      - name: Checkout spt-shell-notify (real-shell dogfood)
        uses: actions/checkout@v5
        with:
          repository: SaberMage/spt-shell-notify
          path: .adapter-notify
      - name: Build notify-shell
        run: cargo build --manifest-path .adapter-notify/Cargo.toml
      - name: Real-shell E2E (notify) — linux
        if: runner.os == 'Linux'
        run: cargo test -p spt --test notify_shell_e2e -- --nocapture
        env:
          SPT_NOTIFY_SHELL_BIN: ${{ github.workspace }}/.adapter-notify/target/debug/notify-shell
      - name: Real-shell E2E (notify) — windows
        if: runner.os == 'Windows'
        run: cargo test -p spt --test notify_shell_e2e -- --nocapture
        env:
          SPT_NOTIFY_SHELL_BIN: ${{ github.workspace }}\.adapter-notify\target\debug\notify-shell.exe

      # ---- M6-D2 installer-script rung ----
      # Runs the real install.sh / install.ps1 against a STAGED release
      # artifact (the just-built workspace binary laid out asset-name +
      # SHA256SUMS) on the matching runner, finishing with the installed
      # binary's `spt --version`. Env-gated: the normal sweep silent-skips
      # the same test when SPT_INSTALL_E2E is unset. Hermetic — temp HOME on
      # Linux, SPT_INSTALL_NO_PATH on Windows (never writes the runner's
      # real user PATH). Named oneliner_e2e: an exe named *install* trips
      # Windows' UAC installer heuristic (os error 740).
      - name: Installer E2E (staged-release rung)
        run: cargo test -p spt --test oneliner_e2e -- --nocapture
        env:
          SPT_INSTALL_E2E: "1"

      # The E2E steps above autostart detached `spt` daemons (REQ-DAEMON-3) that
      # may not reap before this step. On Windows a running `spt.exe` cannot be
      # overwritten, so the `xtask check` rebuild below fails with os-error-5
      # ("failed to remove target\debug\spt.exe"). Reap ONLY the leaked daemons
      # launched from THIS checkout's target dir — scoped to $GITHUB_WORKSPACE so
      # it never touches a live agent's daemon/broker (hfenduleam is a
      # SELF-HOSTED runner sharing the machine with live agents; a machine-wide
      # `spt.exe` kill would tear down production). A null Path row is skipped by
      # the `-like` (the safe direction). Linux can overwrite a running exe, so
      # this guard is Windows-only.
      - name: Reap stray daemons before docs-drift build (Windows exe-lock guard)
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          Get-Process spt,notify-shell -ErrorAction SilentlyContinue |
            Where-Object { $_.Path -like "$env:GITHUB_WORKSPACE\*" } |
            Stop-Process -Force -ErrorAction SilentlyContinue
          exit 0

      # ---- M6-D5 docs drift gates ----
      # The generated CLI reference must equal the binary's own --help, and
      # every link in the curated llms.txt must resolve (agents cache links).
      # The schema gate (checked_in_schema_is_current) already rides the
      # normal test sweep.
      # [int->REQ-DOCS-5]
      - name: Docs drift gate (CLI ref + llms links)
        run: cargo run -p xtask -- check

      # ---- D9-3 dormancy-budget measurement (ADR-0003 red-team #9) ----
      # Opt-in per push: runs ONLY when the head commit message carries
      # "[budget]" — both rig hosts measure in one tagged push, output lands
      # in the run log and is captured into docs/DORMANCY-BUDGET.md. The
      # harness itself is also env-gated (SPT_BUDGET=1), so an untagged CI
      # run silent-skips it inside the normal test sweep.
      - name: Dormancy budget (shell seats)
        if: contains(github.event.head_commit.message, '[budget]')
        run: cargo test -p spt-daemon --test budget --release -- --nocapture
        env:
          SPT_BUDGET: "1"
      # Two shells: the Windows runner has no bash on PATH (the first tagged
      # run failed on `shell: bash` there), so each OS keeps its native one.
      - name: Dormancy budget (real adapter seats, when installed) — linux
        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Linux'
        shell: bash
        run: |
          if command -v claude >/dev/null; then
            SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \
              cargo test -p spt-daemon --test budget --release -- --nocapture
          else
            echo "claude not installed on this runner — shell numbers only"
          fi
      - name: Dormancy budget (real adapter seats, when installed) — windows
        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Windows'
        shell: powershell
        run: |
          if (Get-Command claude -ErrorAction SilentlyContinue) {
            $env:SPT_BUDGET = "1"
            $env:SPT_BUDGET_N = "3"
            $env:SPT_BUDGET_PROGRAM = "claude"
            $env:SPT_BUDGET_SETTLE_MS = "15000"
            cargo test -p spt-daemon --test budget --release -- --nocapture
          } else {
            # ASCII only: Windows PowerShell 5.1 reads the step's temp script
            # without a BOM as ANSI, so a multibyte dash corrupts the string
            # terminator (the first tagged-run failure mode here).
            Write-Output "claude not installed on this runner - shell numbers only"
          }

  # ---- D7-2 new-brain × old-broker N-1 verb-surface gate (ADR-0018 V6) ----
  # Steady state after every routine brain-only update is a NEW brain talking to
  # an OLDER broker, so the current brain must serve the socket verbs against a
  # prior-era broker without a clap/arity reject or a verb desync (KH-2.3). The
  # "old broker" is the restoration split boundary — D1 skeleton 0c95435 (a
  # pre-restoration release has no brain supervisor and cannot pair; doyle D7 vet
  # call 4a). It is built ONCE into a stable cache keyed by the pin SHA (mirrors
  # the traceability job's self-heal) and reused until the pin advances — to the
  # latest PUBLISHED split-era release once one exists. The n1_pairing harness is
  # env-gated (SPT_N1_OLD_BROKER), so the normal `cargo test` sweep silent-skips
  # it; this job sets the env and turns the skip into a real gate on both runners.
  n1-gate:
    strategy:
      fail-fast: false
      matrix:
        runner: [[self-hosted, Linux, kitsubito], [self-hosted, Windows, hfenduleam]]
    runs-on: ${{ matrix.runner }}
    # Headroom for the first-ever cold pin build (the multi-exe iroh link);
    # cached thereafter, so steady-state runs are a warm test + seconds.
    timeout-minutes: 40
    steps:
      - uses: actions/checkout@v5
        with:
          # The pinned old-broker ref (0c95435) must be reachable to build it.
          fetch-depth: 0
      # Same Linux mold linker as the `test` job — and, since both Linux jobs run
      # on the same runner and share its target dir, matching RUSTFLAGS lets this
      # job REUSE the `test` job's warm artifacts instead of a cold full rebuild.
      - name: Use mold linker (Linux)
        if: runner.os == 'Linux'
        run: echo 'RUSTFLAGS=-C link-arg=-fuse-ld=mold' >> "$GITHUB_ENV"
      - name: N-1 gate (new-brain × old-broker) — linux
        if: runner.os == 'Linux'
        shell: bash
        run: |
          set -euo pipefail
          PIN=0c95435
          CACHE="$HOME/spt-n1-oldbroker"
          BIN="$CACHE/target/debug/spt"
          # Self-healing cache: rebuild the pin only when it moves. Clone OUTSIDE
          # the workspace (a clone inside gets adopted by the cargo workspace).
          if [ ! -x "$BIN" ] || [ "$(cat "$CACHE/.builtpin" 2>/dev/null || true)" != "$PIN" ]; then
            rm -rf "$CACHE"
            git clone "$GITHUB_WORKSPACE" "$CACHE"
            git -C "$CACHE" checkout "$PIN"
            cargo build --manifest-path "$CACHE/Cargo.toml" -p spt --bin spt
            echo "$PIN" > "$CACHE/.builtpin"
          fi
          SPT_N1_OLD_BROKER="$BIN" cargo test -p spt --test n1_pairing -- --nocapture
      - name: N-1 gate (new-brain × old-broker) — windows
        if: runner.os == 'Windows'
        # pwsh (PowerShell 7), NOT `powershell` (5.1): the runner's 5.1 execution
        # policy refuses the unsigned temp script GitHub generates
        # (PSSecurityException) — the same default shell the green `test` job uses.
        shell: pwsh
        run: |
          $ErrorActionPreference = "Stop"
          $PIN = "0c95435"
          $CACHE = "$env:USERPROFILE\spt-n1-oldbroker"
          $BIN = "$CACHE\target\debug\spt.exe"
          $built = if (Test-Path "$CACHE\.builtpin") { (Get-Content "$CACHE\.builtpin").Trim() } else { "" }
          if (-not (Test-Path $BIN) -or $built -ne $PIN) {
            if (Test-Path $CACHE) { Remove-Item -Recurse -Force $CACHE }
            git clone $env:GITHUB_WORKSPACE $CACHE
            git -C $CACHE checkout $PIN
            cargo build --manifest-path "$CACHE\Cargo.toml" -p spt --bin spt
            Set-Content "$CACHE\.builtpin" $PIN
          }
          $env:SPT_N1_OLD_BROKER = $BIN
          cargo test -p spt --test n1_pairing -- --nocapture

  # ---- D9-6 two-host rig ladder (M4-PLAN §D9b) ----
  # Opt-in per push: runs ONLY when the head commit message carries
  # "[twohost]". The two rig hosts ARE the self-hosted runners, so one tagged
  # push runs both roles; `needs: test` releases the two jobs together (each
  # runner has one slot — without it one role could sit queued behind the
  # other machine's test leg and starve the pairing window). The harness is
  # also env-gated (SPT_TWO_HOST=1), so untagged runs silent-skip it inside
  # the normal sweep. Not a standing CI job: the recurring cross-runner
  # choreography stays an M5 seam (docs/TWO-HOST-RUNBOOK.md is the proof
  # procedure).
  twohost-b:
    if: contains(github.event.head_commit.message, '[twohost]')
    needs: test
    runs-on: [self-hosted, Linux, kitsubito]
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v5
      # The M5 notif→toast rung renders through the REAL notify shell
      # (M5-D9a; same cross-repo hook as the test job's E2E).
      - name: Checkout spt-shell-notify (toast rung)
        uses: actions/checkout@v5
        with:
          repository: SaberMage/spt-shell-notify
          path: .adapter-notify
      - name: Build notify-shell
        run: cargo build --manifest-path .adapter-notify/Cargo.toml
      # The real-mode toast rung also needs the workspace `spt` binary beside
      # the test executable (the resident shell drives `spt api`); the first
      # D9a run proved the twohost job's cargo context does not share the
      # test job's artifacts — build it in-job (warm: seconds).
      - name: Build spt binary (toast rung)
        run: cargo build -p spt --bin spt
      - name: Two-host ladder — role B (seed-holder/server)
        run: cargo test -p spt-daemon --test twohost -- --nocapture
        env:
          SPT_TWO_HOST: "1"
          SPT_TWO_HOST_ROLE: "b"
          SPT_TWO_HOST_SECRET: "rig-twohost-ci"
          SPT_TWO_HOST_PEER_IP: "100.68.35.65" # hfenduleam (tailscale)
          SPT_TWO_HOST_WAIT_SECS: "900"
          SPT_TWO_HOST_NOTIFY_BIN: ${{ github.workspace }}/.adapter-notify/target/debug/notify-shell
  twohost-a:
    if: contains(github.event.head_commit.message, '[twohost]')
    needs: test
    runs-on: [self-hosted, Windows, hfenduleam]
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v5
      - name: Checkout spt-shell-notify (toast rung)
        uses: actions/checkout@v5
        with:
          repository: SaberMage/spt-shell-notify
          path: .adapter-notify
      - name: Build notify-shell
        run: cargo build --manifest-path .adapter-notify/Cargo.toml
      - name: Build spt binary (toast rung)
        run: cargo build -p spt --bin spt
      - name: Two-host ladder — role A (joiner/driver)
        run: cargo test -p spt-daemon --test twohost -- --nocapture
        env:
          SPT_TWO_HOST: "1"
          SPT_TWO_HOST_ROLE: "a"
          SPT_TWO_HOST_SECRET: "rig-twohost-ci"
          SPT_TWO_HOST_PEER_IP: "100.98.197.12" # kitsubito (tailscale)
          SPT_TWO_HOST_WAIT_SECS: "900"
          SPT_TWO_HOST_NOTIFY_BIN: ${{ github.workspace }}\.adapter-notify\target\debug\notify-shell.exe

  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
  traceability:
    runs-on: [self-hosted, Linux, kitsubito]
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v5
      - name: Install traceable-reqs
        # Source-pinned, self-healing (M6-D2): kitsubito cannot run the release
        # asset (it needs GLIBC 2.39; kitsubito's Ubuntu 22.04 has 2.35) — so
        # the job itself builds the pinned tag once and caches it in
        # ~/.local/bin, rebuilding only when the pin moves. v0.1.2 =
        # shell/PowerShell tag scanning (the M6 installer scripts carry
        # impl-stage REQ-INSTALL evidence).
        run: |
          WANT=0.1.2
          BIN="$HOME/.local/bin/traceable-reqs"
          if [ -x "$BIN" ] && "$BIN" --version | grep -q " $WANT\$"; then
            ln -sf "$BIN" ./traceable-reqs
          else
            # Build OUTSIDE the checkout: a clone inside it gets adopted by
            # the spt-core cargo workspace and refuses to build.
            SRC="$RUNNER_TEMP/tr-src"
            rm -rf "$SRC"
            git clone --depth 1 --branch "v$WANT" \
              "https://x-access-token:${GH_TOKEN}@github.com/BigscreenVR/traceable-reqs" "$SRC"
            cargo build --release --manifest-path "$SRC/rust/Cargo.toml"
            mkdir -p "$HOME/.local/bin"
            cp "$SRC/rust/target/release/traceable-reqs" "$BIN"
            ln -sf "$BIN" ./traceable-reqs
          fi
          ./traceable-reqs --version
        env:
          # Cross-org private repo: the default repo-scoped GITHUB_TOKEN cannot
          # read BigscreenVR/traceable-reqs. Supply a PAT with read access to it
          # as the TRACEABLE_REQS_TOKEN repo secret. See README/CI notes.
          GH_TOKEN: ${{ secrets.TRACEABLE_REQS_TOKEN }}
      - name: Coverage gate (hard — fails PR on uncovered activated req)
        run: ./traceable-reqs check --json
      - name: Quality audit (advisory)
        run: ./traceable-reqs lint || true
